From 8098dcc282415b7d51c083d891490350818db20b Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Tue, 11 Aug 2026 19:34:02 +0800 Subject: [PATCH 1/5] Add AI provider and binding CLI support Parse Wrangler [ai] bindings, add namespace-scoped provider, credential, and model commands, ship an agent-ready Responses tool-loop example, and document deployment, credential rotation, OpenAI-compatible inference, streaming, and WebSocket usage without exposing provider credentials. Signed-off-by: Lu Zhang --- .claude/rules/examples.md | 4 +- .claude/skills/wdl-deploy/SKILL.md | 14 +- CHANGELOG.md | 6 + GUIDE-zh.md | 41 +++- GUIDE.md | 139 +++++++---- README-zh.md | 9 +- README.md | 18 +- bin/wdl.js | 4 +- commands/ai.js | 256 ++++++++++++++++++++ docs/README-zh.md | 2 + docs/README.md | 2 + docs/ai-zh.md | 191 +++++++++++++++ docs/ai.md | 235 ++++++++++++++++++ docs/deploy-zh.md | 5 +- docs/deploy.md | 16 +- docs/env-overrides-zh.md | 4 +- docs/env-overrides.md | 6 +- examples/ai-agent-demo/package.json | 8 + examples/ai-agent-demo/provider.openai.json | 20 ++ examples/ai-agent-demo/src/index.js | 82 +++++++ examples/ai-agent-demo/wrangler.toml | 6 + lib/wrangler-pack.js | 8 + lib/wrangler/bindings.js | 23 ++ lib/wrangler/config.js | 7 +- templates/AGENTS.md | 17 +- tests/unit/cli-ai.test.js | 245 +++++++++++++++++++ tests/unit/cli-deploy.test.js | 8 +- tests/unit/cli-wrangler-bindings.test.js | 12 + tests/unit/cli-wrangler-config.test.js | 5 +- 29 files changed, 1298 insertions(+), 95 deletions(-) create mode 100644 commands/ai.js create mode 100644 docs/ai-zh.md create mode 100644 docs/ai.md create mode 100644 examples/ai-agent-demo/package.json create mode 100644 examples/ai-agent-demo/provider.openai.json create mode 100644 examples/ai-agent-demo/src/index.js create mode 100644 examples/ai-agent-demo/wrangler.toml create mode 100644 tests/unit/cli-ai.test.js diff --git a/.claude/rules/examples.md b/.claude/rules/examples.md index d231a82..a6ef96f 100644 --- a/.claude/rules/examples.md +++ b/.claude/rules/examples.md @@ -28,12 +28,14 @@ requested feature set better than the minimal init template. | `queues-demo` | Queue producer + consumer in one routeable worker, with consumed message state in KV. `wrangler.toml`. Use when requests should enqueue background work. | | `durable-objects-demo` | Same-worker Durable Object class with SQLite-backed counter state. `wrangler.toml`. Use when one logical object needs serialized state. | | `workflows-demo` | Workflow class with start/status/approval routes. `wrangler.toml`. Use when work spans multiple durable steps or needs CLI-visible instance state. | +| `ai-agent-demo` | Responses function-tool loop through `env.AI`. `wrangler.toml`. Use when a Worker needs OpenAI-compatible agent inference without receiving provider credentials. | | `env-overrides-demo` | `[env.preview]` and `[env.production]` blocks showing WDL-specific env override behavior: no worker-name suffix, env-scoped vars that do not inherit top-level vars, and assets override. `wrangler.toml`. Layer on top of any of the above when you need env-specific config. | | `inspection-demo` | Multi-binding example combining D1 + KV + R2 + assets. `wrangler.toml`. Use as a reference when the worker needs more than one binding. | To pick: for a worker that serves a page or fronts an external API, start from `pages-assets`. For pure compute, cron, or queue work, start from `hello-jsonc`, -`cron-demo`, or `queues-demo`. +`cron-demo`, or `queues-demo`. For a Responses function-tool agent, start from +`ai-agent-demo`. ## Scaffolding steps diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index c58229b..59f9cc1 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -1,6 +1,6 @@ --- name: wdl-deploy -description: Deploy and manage Cloudflare Workers-style projects on the WDL platform via the `wdl` CLI (init, deploy, config explain, whoami, doctor, tail, secret, workers, delete, d1, r2, workflows). Trigger when the user asks to scaffold or deploy a Worker, inspect resolved CLI configuration, identify the active control token/principal, run diagnostics, tail live logs, configure KV / Queues / Durable Objects / Workflows bindings, manage D1 / R2 / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` projects pinned to wrangler@^4. +description: Deploy and manage Cloudflare Workers-style projects on the WDL platform via the `wdl` CLI (init, deploy, config explain, whoami, doctor, tail, secret, workers, delete, d1, r2, ai, workflows). Trigger when the user asks to scaffold or deploy a Worker, inspect resolved CLI configuration, identify the active control token/principal, run diagnostics, tail live logs, configure KV / Queues / Durable Objects / Workflows / AI bindings, manage D1 / R2 / AI providers / secrets through `wdl`, or troubleshoot wdl CLI output. Works with `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` projects pinned to wrangler@^4. --- # WDL CLI deploy skill @@ -33,6 +33,8 @@ Open the relevant doc before answering: queue handlers, message size and retry limits. - `docs/workflows.md` — `[[workflows]]` config, the WDL Workflows surface, `wdl workflows` instance management. +- `docs/ai.md` — `[ai]` config, namespace provider/credential management, + Responses/tools/SSE, OpenAI SDK use, and WebSocket inference. - `docs/kv.md` — `[[kv_namespaces]]`, immediately visible writes, batch reads, `list()` metadata / pagination differences. - `docs/assets.md` — `[assets]` directory + `env.ASSETS`, size caps, default @@ -67,11 +69,11 @@ hint, preserving the trailing `*` on prefix patterns, and includes the platform-domain URL only while it is enabled. Cloudflare's separate `preview_urls` field is unsupported and rejected by the CLI. WDL-only `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, -`[[services]].ns`, and `[wdl]` are parsed by the CLI and removed from Wrangler's -temporary bundle config; other fields retain their existing Wrangler passthrough -behavior. Specific nested fields that WDL cannot represent are rejected rather -than silently dropped, including Cloudflare Artifacts `triggers.events` -subscriptions and R2 `local_dev.experimental_s3_credentials`. +`[[services]].ns`, `[ai]`, and `[wdl]` are parsed by the CLI and removed from +Wrangler's temporary bundle config; other fields retain their existing Wrangler +passthrough behavior. Specific nested fields that WDL cannot represent are +rejected rather than silently dropped, including Cloudflare Artifacts +`triggers.events` subscriptions and R2 `local_dev.experimental_s3_credentials`. `[wdl] session_policy` accepts `preserve` or `restart`. The default `preserve` leaves loaded Durable Object facets on the version that built them until the host actor restarts or the facet is deleted, and keeps established WebSockets diff --git a/CHANGELOG.md b/CHANGELOG.md index da66784..6eb58f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Added + +- Add the `[ai]` binding, namespace provider and credential management commands, + model discovery, OpenAI-compatible SDK guidance, and a Responses function-tool + agent example. + ## 1.7.1 ### Changed diff --git a/GUIDE-zh.md b/GUIDE-zh.md index afd91b1..75a6f89 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -257,13 +257,14 @@ Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canon | `[[platform_bindings]]` | 支持平台提供的第一方能力,例如平台封装的共享服务 | | `[env.]` | 支持;用 `--env ` 或 `CLOUDFLARE_ENV` 选择;见下面的环境覆盖说明 | | `[[r2_buckets]]` | 支持常用 R2 object API,包括条件请求、range GET 和 `list({ include })`;对象存储在平台本地 R2,并按 namespace + `bucket_name` 隔离 | +| `[ai]` | 支持单例 `{ binding }` 声明。Provider 元数据和加密 namespace 凭据由 `wdl ai` 管理;Worker 按模型描述获得 OpenAI-compatible Responses、Chat Completions、Embeddings、SSE、Responses WebSocket 和 Realtime WebSocket 路径 | | Durable Objects | 支持本 worker 内 class,要求 class 列在 `[[migrations]].new_classes` 或 `[[migrations]].new_sqlite_classes`;两种写法在 WDL 都映射到 SQLite-backed DO storage。`script_name`、rename/delete migration 暂未实现。`stub.fetch()`、JSON-structured `stub.method(...args)` DO RPC、同步 `ctx.storage.sql`、alarm shim、普通 WebSocket upgrade 和 native WebSocket hibernation API surface 可用;平台级 session/cursor 恢复仍由应用自己处理 | | `[wdl]` | WDL 平台扩展表,当前含 `session_policy = "preserve" \| "restart"`(默认 `preserve`);`restart` 下 promotion 会以 `1012` 关闭该 worker 打开的 WebSocket,stale Durable Object facet 在下一次 dispatch 时中止。与 `workers_dev` 一样会被 `[env.]` 继承,除非该 env 自己声明了 `[wdl]` | | `[[workflows]]` | 支持当前 Worker 内定义的 workflow class。可用 `WorkflowEntrypoint`、`env..create()`、`createBatch()`、`get()`、`status()`、`pause()`/`resume()`/`restart()`/`terminate()`、`sendEvent()`、`step.do()`/`sleep()`/`sleepUntil()`/`waitForEvent()`、retry、`NonRetryableError`、same-worker DO progress callback 和 runtime-observed parallel/DAG step。这是 WDL Workflows 支持,不是完整 Cloudflare Workflows parity。Instance payload、单 turn step fan-out 和并行 step 顺序都有上限;已启动的 step 必须 await。不支持 `script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer | | Analytics Engine | 暂不支持,部署时会拒绝 | -| 其他未映射的 Wrangler 绑定/配置/策略段(例如 `ai`、`vectorize`、`hyperdrive`、`agent_memory`、`websearch`、`media`、`stream`、`ratelimits`、`vpc_services`、`cloudchamber`、`containers`、`wasm_modules`、`[site]`、`limits`、`placement`、`observability`、`pages_build_output_dir`) | 不支持;部署时显式报错,不会静默丢弃绑定/配置。CLI 报错会点名被拒字段;内部拒绝列表跟随打包的 Wrangler schema,这里不复刻完整清单 | +| 其他未映射的 Wrangler 绑定/配置/策略段(例如 `vectorize`、`hyperdrive`、`agent_memory`、`websearch`、`media`、`stream`、`ratelimits`、`vpc_services`、`cloudchamber`、`containers`、`wasm_modules`、`[site]`、`limits`、`placement`、`observability`、`pages_build_output_dir`) | 不支持;部署时显式报错,不会静默丢弃绑定/配置。CLI 报错会点名被拒字段;内部拒绝列表跟随打包的 Wrangler schema,这里不复刻完整清单 | -WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 +WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns`、`[ai]` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 Cron triggers 和 queue consumers 是运行时 dispatch 能力。除非管理方明确给了 reserved namespace,否则只应声明在 tenant namespace 里的可路由 Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是公开/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 @@ -277,7 +278,7 @@ R2 object key 可以包含开头、结尾或连续的 `/` 分隔符;CLI 会保 ### 环境覆盖 -如果 Wrangler 配置里有 `[env.]`,必须通过 `--env ` 或 `CLOUDFLARE_ENV` 显式选择;CLI 不会自动挑一个默认环境。和 Cloudflare Workers / Wrangler 不同,WDL 不会把环境名追加到 worker / script 名后面:`wdl deploy . --env preview` 仍然更新顶层 `name` 指定的 worker。`vars` 和大部分 bindings 仍是 env-scoped / non-inheritable:选中 env 后,顶层 `[vars]`、KV、D1、R2、queues、services、workflows 都不会自动进入该 env。策略类配置则会继承:`workers_dev`、`route` / `routes` 和 `[wdl]` 在 env 没有自己声明时继续生效。需要同时跑 staging / production 时,默认用不同 namespace 区分,除非管理方另有约定。 +如果 Wrangler 配置里有 `[env.]`,必须通过 `--env ` 或 `CLOUDFLARE_ENV` 显式选择;CLI 不会自动挑一个默认环境。和 Cloudflare Workers / Wrangler 不同,WDL 不会把环境名追加到 worker / script 名后面:`wdl deploy . --env preview` 仍然更新顶层 `name` 指定的 worker。`vars` 和大部分 bindings 仍是 env-scoped / non-inheritable:选中 env 后,顶层 `[vars]`、KV、D1、R2、AI、queues、services、workflows 都不会自动进入该 env。策略类配置则会继承:`workers_dev`、`route` / `routes` 和 `[wdl]` 在 env 没有自己声明时继续生效。需要同时跑 staging / production 时,默认用不同 namespace 区分,除非管理方另有约定。 ### KV @@ -361,6 +362,37 @@ wdl r2 objects delete uploads images/logo.png --yes `examples/inspection-demo` 展示了 R2 + D1 + KV + Assets 组合使用。 +### AI + +声明单例 binding,然后分别配置 namespace provider 元数据和凭据: + +```toml +[ai] +binding = "AI" +``` + +```bash +wdl ai providers put openai --file provider.openai.json +printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai +wdl ai models +``` + +Provider 元数据选择官方 adapter(`openai`、`xai` 或 `deepseek`),并把 `openai/primary` 这类有界 alias 映射到原生 model id 和 capability。更新元数据会生成新 revision 并清除旧 credential;推理前必须重新配置凭据。凭据是加密的 namespace 资源,不会进入 Worker env 或 bundle metadata。 + +Agent 代码使用 provider 原生 Responses 形态: + +```js +const response = await env.AI.run("openai/primary", { + input: "Choose and call the appropriate tool.", + tools, + reasoning: { effort: "medium" }, +}); +``` + +在模型声明相应 transport 时,`run()` 还支持语义 SSE(`stream: true`)、`AbortSignal`、Chat Completions、Embeddings 和显式 WebSocket mode。`fetch()` 是原始 OpenAI-compatible transport,官方 OpenAI JavaScript SDK 可通过它完成 JSON、SSE 和取消。`models()` 返回当前 namespace 中已配置凭据的模型描述。 + +WDL 不执行 function tool、不自动重连模型 WebSocket、不接受任意 provider endpoint,也不向租户暴露 provider 凭据。Provider JSON、SDK 配置、WebSocket 形态、边界及 `examples/ai-agent-demo` 的完整 tool loop 见 [`docs/ai-zh.md`](./docs/ai-zh.md)。 + ### D1 部署绑定 D1 的 Worker 前,先创建数据库: @@ -885,10 +917,11 @@ wdl tail hello | Queues | 部分 | — | 按 batch 大小驱动派发;`max_batch_timeout` 为配置兼容而保存,不是聚合窗口 | `max_concurrency`(显式拒绝)、`contentType: "v8"` | | Cron 触发器 | 支持 | — | Cloudflare 兼容表达式,按 UTC 执行;best-effort 分钟槽——错过的槽跳过不补发,失败不重试 | Cloudflare Artifacts `triggers.events` subscription | | Workflows | 部分 | 并行 / DAG step 在运行时实测捕获,包括 `Promise.all` 并行分支 | WDL 自有的 payload 语义;payload 与单 turn step fan-out 有上限;严格 await 顺序;`step.do` 永久失败即终止运行(即使被 catch) | 完整 Cloudflare Workflows 对等、`script_name` / 跨 worker workflow 与 callback、source-AST 可视化 | +| Workers AI | 部分 | Namespace BYO 凭据不进入租户 env;提供 raw OpenAI-compatible fetch 和官方 SDK 使用路径 | `run()` 保留 provider 原生 OpenAI protocol object,而不是 Cloudflare 每模型的后处理输出;模型 id 为 `/` | 托管 catalog/凭据、usage/quota、AI Gateway、`toMarkdown()`、异步 batch、background Responses、WebRTC、SIP | | Service bindings | 支持 | — | — | — | | Platform bindings | 支持 | WDL 新增、Cloudflare 无对应物:运维方管控的平台能力经 `[[platform_bindings]]` 注入 `env` | — | — | | Vars 与 secrets | 支持 | — | Secrets 由平台管理(`wdl secret` 写入),不是 Cloudflare 账号 secrets | — | | Cache API(`caches.default`) | 不支持 | — | — | 未暴露;不要依赖它 | -| Workers AI、Vectorize、Analytics Engine、Browser Rendering、Hyperdrive、Email | 不支持 | — | — | 无 binding;部署阶段显式拒绝这些配置段 | +| Vectorize、Analytics Engine、Browser Rendering、Hyperdrive、Email | 不支持 | — | — | 无 binding;部署阶段显式拒绝这些配置段 | 与 Cloudflare 账号资源无关:`kv_namespaces.id`、queue 名、platform binding 名都是本平台内的资源名。 diff --git a/GUIDE.md b/GUIDE.md index e89dfd8..744d632 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -369,34 +369,35 @@ and secret mutation also enforce the headroomed 1 MiB workerd `workerLoader` env budget; large `[vars]`, secrets, binding metadata, or retained versions can fail with `worker_env_too_large`. -| Configuration | Support | -| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `name` / `main` / `compatibility_date` / `compatibility_flags` | Supported | -| `[vars]` | Supported; must be an object. Values must be string / number / boolean; arrays and nested values are rejected. Accepted values are exposed through Worker `env` | -| `[[kv_namespaces]]` | Supported for common KV APIs | -| `[[d1_databases]]` | Supported for bindings; create/manage databases with `wdl d1`, then reference them by `database_id` (preferred when present) or `database_name` (namespace-unique alias) | -| `[assets] directory = "..."` | Supported; static files are deployed to platform assets, and the Worker gets `env.ASSETS.url(path)` | -| `route` / `routes` | Not generally available for tenant self-service; use only when your operator explicitly enables a custom host for your namespace | -| `workers_dev` | Optional boolean for a Worker with at least one `route` / `routes` pattern. `false` disables the default WDL platform-domain URL; omitted or `true` keeps it enabled | -| `[triggers] crons` | Supported; Cloudflare-compatible form, executed in UTC | -| `[[triggers.schedules]]` | Platform extension; each cron can specify its own `timezone`; not part of standard Cloudflare configuration | -| `triggers.events` | Not supported; Cloudflare Artifacts event subscriptions have no WDL control/runtime mapping and are rejected before bundling | -| `[[queues.producers]]` / `[[queues.consumers]]` | Supported for producing and consuming queues; `delivery_delay` and `retry_delay` are honored, while `max_concurrency` is rejected | -| `[[services]]` | Supported for Worker-to-Worker calls; same namespace works directly, cross-namespace calls require target-side authorization | -| `[[platform_bindings]]` | Supported for platform-provided first-party capabilities | -| `[env.]` | Supported; select with `--env ` or `CLOUDFLARE_ENV`; see environment override notes below | -| `[[r2_buckets]]` | Supported for common R2 object APIs, including conditional requests, range GETs, and `list({ include })`; objects are stored in platform-local R2 and isolated by namespace + `bucket_name` | -| Durable Objects | Supported for local classes listed in `[[migrations]].new_classes` or `[[migrations]].new_sqlite_classes`; both map to SQLite-backed DO storage in WDL. `script_name` and renamed/deleted migrations are not supported yet. `stub.fetch()`, JSON-structured `stub.method(...args)` DO RPC, synchronous `ctx.storage.sql`, the alarm shim, ordinary WebSocket upgrade, and the native WebSocket hibernation API surface are available; platform-level session/cursor recovery remains application-owned | -| `[wdl]` | WDL platform extension table, currently `session_policy = "preserve" \| "restart"` (default `preserve`); under `restart`, promotion closes the worker's open WebSockets with `1012` and stale Durable Object facets abort on their next dispatch. Inherited by `[env.]` like `workers_dev` unless that env declares its own `[wdl]` | -| `[[workflows]]` | Supported for workflow classes defined in the current Worker. `WorkflowEntrypoint`, `env..create()`, `createBatch()`, `get()`, `status()`, `pause()`/`resume()`/`restart()`/`terminate()`, `sendEvent()`, `step.do()`/`sleep()`/`sleepUntil()`/`waitForEvent()`, retries, `NonRetryableError`, same-worker DO progress callbacks, and runtime-observed parallel/DAG steps are available. This is WDL Workflows support, not full Cloudflare Workflows parity. Instance payloads, per-turn step fan-out, and parallel step ordering are bounded; started steps must be awaited. `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and Cloudflare source-AST visualizer are unsupported | -| Analytics Engine | Not currently supported; deploy fails if configured | -| Other unmapped Wrangler binding/config/policy sections (for example `ai`, `vectorize`, `hyperdrive`, `agent_memory`, `websearch`, `media`, `stream`, `ratelimits`, `vpc_services`, `cloudchamber`, `containers`, `wasm_modules`, `[site]`, `limits`, `placement`, `observability`, `pages_build_output_dir`) | Not supported; deploy fails loudly instead of silently dropping the binding/config. The CLI error names the rejected field; the internal rejection list tracks the bundled Wrangler schema and is not reproduced exhaustively here | +| Configuration | Support | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` / `main` / `compatibility_date` / `compatibility_flags` | Supported | +| `[vars]` | Supported; must be an object. Values must be string / number / boolean; arrays and nested values are rejected. Accepted values are exposed through Worker `env` | +| `[[kv_namespaces]]` | Supported for common KV APIs | +| `[[d1_databases]]` | Supported for bindings; create/manage databases with `wdl d1`, then reference them by `database_id` (preferred when present) or `database_name` (namespace-unique alias) | +| `[assets] directory = "..."` | Supported; static files are deployed to platform assets, and the Worker gets `env.ASSETS.url(path)` | +| `route` / `routes` | Not generally available for tenant self-service; use only when your operator explicitly enables a custom host for your namespace | +| `workers_dev` | Optional boolean for a Worker with at least one `route` / `routes` pattern. `false` disables the default WDL platform-domain URL; omitted or `true` keeps it enabled | +| `[triggers] crons` | Supported; Cloudflare-compatible form, executed in UTC | +| `[[triggers.schedules]]` | Platform extension; each cron can specify its own `timezone`; not part of standard Cloudflare configuration | +| `triggers.events` | Not supported; Cloudflare Artifacts event subscriptions have no WDL control/runtime mapping and are rejected before bundling | +| `[[queues.producers]]` / `[[queues.consumers]]` | Supported for producing and consuming queues; `delivery_delay` and `retry_delay` are honored, while `max_concurrency` is rejected | +| `[[services]]` | Supported for Worker-to-Worker calls; same namespace works directly, cross-namespace calls require target-side authorization | +| `[[platform_bindings]]` | Supported for platform-provided first-party capabilities | +| `[env.]` | Supported; select with `--env ` or `CLOUDFLARE_ENV`; see environment override notes below | +| `[[r2_buckets]]` | Supported for common R2 object APIs, including conditional requests, range GETs, and `list({ include })`; objects are stored in platform-local R2 and isolated by namespace + `bucket_name` | +| `[ai]` | Supported as a singleton `{ binding }` declaration. Provider metadata and encrypted namespace credentials are managed with `wdl ai`; Workers receive OpenAI-compatible Responses, Chat Completions, Embeddings, SSE, Responses WebSocket, and Realtime WebSocket paths according to each model descriptor | +| Durable Objects | Supported for local classes listed in `[[migrations]].new_classes` or `[[migrations]].new_sqlite_classes`; both map to SQLite-backed DO storage in WDL. `script_name` and renamed/deleted migrations are not supported yet. `stub.fetch()`, JSON-structured `stub.method(...args)` DO RPC, synchronous `ctx.storage.sql`, the alarm shim, ordinary WebSocket upgrade, and the native WebSocket hibernation API surface are available; platform-level session/cursor recovery remains application-owned | +| `[wdl]` | WDL platform extension table, currently `session_policy = "preserve" \| "restart"` (default `preserve`); under `restart`, promotion closes the worker's open WebSockets with `1012` and stale Durable Object facets abort on their next dispatch. Inherited by `[env.]` like `workers_dev` unless that env declares its own `[wdl]` | +| `[[workflows]]` | Supported for workflow classes defined in the current Worker. `WorkflowEntrypoint`, `env..create()`, `createBatch()`, `get()`, `status()`, `pause()`/`resume()`/`restart()`/`terminate()`, `sendEvent()`, `step.do()`/`sleep()`/`sleepUntil()`/`waitForEvent()`, retries, `NonRetryableError`, same-worker DO progress callbacks, and runtime-observed parallel/DAG steps are available. This is WDL Workflows support, not full Cloudflare Workflows parity. Instance payloads, per-turn step fan-out, and parallel step ordering are bounded; started steps must be awaited. `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and Cloudflare source-AST visualizer are unsupported | +| Analytics Engine | Not currently supported; deploy fails if configured | +| Other unmapped Wrangler binding/config/policy sections (for example `vectorize`, `hyperdrive`, `agent_memory`, `websearch`, `media`, `stream`, `ratelimits`, `vpc_services`, `cloudchamber`, `containers`, `wasm_modules`, `[site]`, `limits`, `placement`, `observability`, `pages_build_output_dir`) | Not supported; deploy fails loudly instead of silently dropping the binding/config. The CLI error names the rejected field; the internal rejection list tracks the bundled Wrangler schema and is not reproduced exhaustively here | WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, -`[[services]].ns`, and `[wdl]` itself and removes these private extensions from -the temporary config passed to the Wrangler bundler. Other fields retain their -existing Wrangler passthrough behavior. Wrangler's object-shaped declarative -`exports` configuration is not supported by WDL. +`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes these private +extensions from the temporary config passed to the Wrangler bundler. Other +fields retain their existing Wrangler passthrough behavior. Wrangler's +object-shaped declarative `exports` configuration is not supported by WDL. Cron triggers and queue consumers are dispatch features. Declare them only on routeable Workers in tenant namespaces unless your operator gives you an @@ -438,8 +439,8 @@ choose a default environment. Unlike Cloudflare Workers / Wrangler, WDL does not append the environment name to the worker / script name: `wdl deploy . --env preview` still updates the top-level `name`. `vars` and most bindings remain env-scoped and non-inheritable: selecting an env does not carry -top-level `[vars]`, KV, D1, R2, queues, services, or workflows into that env. -Policies do inherit: `workers_dev`, `route` / `routes`, and `[wdl]` keep +top-level `[vars]`, KV, D1, R2, AI, queues, services, or workflows into that +env. Policies do inherit: `workers_dev`, `route` / `routes`, and `[wdl]` keep applying unless the env declares its own. For staging and production side by side, use separate namespaces unless your operator tells you otherwise. @@ -547,6 +548,49 @@ wdl r2 objects delete uploads images/logo.png --yes See `examples/inspection-demo` for a combined R2 + D1 + KV + Assets example. +### AI + +Declare the singleton binding, then configure namespace provider metadata and a +credential separately: + +```toml +[ai] +binding = "AI" +``` + +```bash +wdl ai providers put openai --file provider.openai.json +printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai +wdl ai models +``` + +Provider metadata selects one official adapter (`openai`, `xai`, or `deepseek`) +and maps bounded aliases such as `openai/primary` to native model ids and +capabilities. Updating metadata creates a new revision and clears the old +credential; configure it again before inference. Credentials are encrypted +namespace resources and never enter Worker env or bundle metadata. + +Agent code uses the provider-native Responses shape: + +```js +const response = await env.AI.run("openai/primary", { + input: "Choose and call the appropriate tool.", + tools, + reasoning: { effort: "medium" }, +}); +``` + +`run()` also supports semantic SSE (`stream: true`), `AbortSignal`, Chat +Completions, Embeddings, and explicit WebSocket mode when the model advertises +the matching transport. `fetch()` is the raw OpenAI-compatible transport and +works with the official OpenAI JavaScript SDK for JSON, SSE, and cancellation. +`models()` returns the current namespace's credential-backed model descriptors. + +WDL does not execute function tools, auto-reconnect model WebSockets, accept +arbitrary provider endpoints, or expose provider credentials. See +[`docs/ai.md`](./docs/ai.md) for provider JSON, the SDK setup, WebSocket shape, +limits, and the `examples/ai-agent-demo` end-to-end tool loop. + ### D1 Create the database before deploying a Worker that binds it: @@ -1239,25 +1283,26 @@ eventually consistent) and capabilities WDL adds beyond Cloudflare. just things to know. **Not implemented** means the surface genuinely does not exist here. -| Surface | Status | Stronger / added on WDL | Different from Cloudflare | Not implemented | -| ----------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| Module Workers (`fetch` / `scheduled` / `queue`) | Supported | — | An uncaught exception returns a platform `502 runtime_error`; exception detail goes to `wdl tail` and logs, not the response body | — | -| WebSocket upgrade | Supported | Connections survive a promotion while the pinned version's backend stays healthy, unless the worker opts into `session_policy = "restart"` | Cloudflare disconnects every WebSocket on deploy; WDL sends `1012` when a lost backend's version is no longer active, or at promotion under `session_policy = "restart"`, and the client reconnects to the active version | Automatic reconnection — reconnect is application-owned, and gateway rolling restarts still drop physical client sockets | -| Streaming responses, outbound TCP (`cloudflare:sockets`) | Supported | — | Tenant workers dial public endpoints only; platform-internal addresses are blocked | — | -| `compatibility_date` / `compatibility_flags` | Partial | — | The platform runs one workerd configuration; historical Cloudflare behavior changes are not emulated per worker | — | -| KV | Supported | Writes are immediately visible — strong consistency where Cloudflare's edge replication is eventually consistent | `cacheTtl` is accepted but is not a freshness contract | — | -| R2 | Supported | — | Single-region object store | Multipart upload, `preview_bucket_name`, `jurisdiction`, `local_dev.experimental_s3_credentials` | -| Static assets | Partial | `env.ASSETS.url(path)` hands out tokenized CDN URLs — a WDL addition | — | Cloudflare Pages-style asset pipeline, fetch-style assets binding | -| D1 | Partial | Single primary database — read-your-writes by default, no replication lag or bookmark semantics to reason about | Request/result sizes are capped. Lifecycle and migrations are managed with `wdl d1`; `[[d1_databases]]` is the binding declaration only | Read replication, Time Travel / bookmarks | -| Durable Objects | Partial | `[wdl] session_policy` chooses whether a promotion keeps or retires established sessions (`preserve` default; Cloudflare always restarts) | Same-worker classes; `new_classes` and `new_sqlite_classes` are equivalent on WDL | `script_name` (cross-script bindings), rename/delete migrations, WebSocket session/cursor recovery | -| Queues | Partial | — | Batching is size-driven; `max_batch_timeout` is stored for config compatibility but is not an aggregation window | `max_concurrency` (rejected loudly), `contentType: "v8"` | -| Cron triggers | Supported | — | Cloudflare-compatible expressions, executed in UTC; best-effort minute slots — missed slots are skipped, never replayed, and failures are not retried | Cloudflare Artifacts `triggers.events` subscriptions | -| Workflows | Partial | Parallel / DAG steps are observed at runtime, including `Promise.all` siblings | WDL-specific payload semantics; bounded payloads and per-turn step fan-out; strict await ordering; a permanently failed `step.do` is terminal even if caught | Full Cloudflare Workflows parity, `script_name` / cross-worker workflows and callbacks, source-AST visualizer | -| Service bindings | Supported | — | — | — | -| Platform bindings | Supported | A WDL addition with no Cloudflare counterpart: operator-curated capabilities injected into `env` via `[[platform_bindings]]` | — | — | -| Vars and secrets | Supported | — | Secrets are platform-managed via `wdl secret`, not Cloudflare account secrets | — | -| Cache API (`caches.default`) | Not supported | — | — | Not exposed; do not depend on it | -| Workers AI, Vectorize, Analytics Engine, Browser Rendering, Hyperdrive, Email | Not supported | — | — | No binding exists; deploy rejects these config sections loudly | +| Surface | Status | Stronger / added on WDL | Different from Cloudflare | Not implemented | +| ----------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Module Workers (`fetch` / `scheduled` / `queue`) | Supported | — | An uncaught exception returns a platform `502 runtime_error`; exception detail goes to `wdl tail` and logs, not the response body | — | +| WebSocket upgrade | Supported | Connections survive a promotion while the pinned version's backend stays healthy, unless the worker opts into `session_policy = "restart"` | Cloudflare disconnects every WebSocket on deploy; WDL sends `1012` when a lost backend's version is no longer active, or at promotion under `session_policy = "restart"`, and the client reconnects to the active version | Automatic reconnection — reconnect is application-owned, and gateway rolling restarts still drop physical client sockets | +| Streaming responses, outbound TCP (`cloudflare:sockets`) | Supported | — | Tenant workers dial public endpoints only; platform-internal addresses are blocked | — | +| `compatibility_date` / `compatibility_flags` | Partial | — | The platform runs one workerd configuration; historical Cloudflare behavior changes are not emulated per worker | — | +| KV | Supported | Writes are immediately visible — strong consistency where Cloudflare's edge replication is eventually consistent | `cacheTtl` is accepted but is not a freshness contract | — | +| R2 | Supported | — | Single-region object store | Multipart upload, `preview_bucket_name`, `jurisdiction`, `local_dev.experimental_s3_credentials` | +| Static assets | Partial | `env.ASSETS.url(path)` hands out tokenized CDN URLs — a WDL addition | — | Cloudflare Pages-style asset pipeline, fetch-style assets binding | +| D1 | Partial | Single primary database — read-your-writes by default, no replication lag or bookmark semantics to reason about | Request/result sizes are capped. Lifecycle and migrations are managed with `wdl d1`; `[[d1_databases]]` is the binding declaration only | Read replication, Time Travel / bookmarks | +| Durable Objects | Partial | `[wdl] session_policy` chooses whether a promotion keeps or retires established sessions (`preserve` default; Cloudflare always restarts) | Same-worker classes; `new_classes` and `new_sqlite_classes` are equivalent on WDL | `script_name` (cross-script bindings), rename/delete migrations, WebSocket session/cursor recovery | +| Queues | Partial | — | Batching is size-driven; `max_batch_timeout` is stored for config compatibility but is not an aggregation window | `max_concurrency` (rejected loudly), `contentType: "v8"` | +| Cron triggers | Supported | — | Cloudflare-compatible expressions, executed in UTC; best-effort minute slots — missed slots are skipped, never replayed, and failures are not retried | Cloudflare Artifacts `triggers.events` subscriptions | +| Workflows | Partial | Parallel / DAG steps are observed at runtime, including `Promise.all` siblings | WDL-specific payload semantics; bounded payloads and per-turn step fan-out; strict await ordering; a permanently failed `step.do` is terminal even if caught | Full Cloudflare Workflows parity, `script_name` / cross-worker workflows and callbacks, source-AST visualizer | +| Workers AI | Partial | Namespace BYO credentials stay outside tenant env; raw OpenAI-compatible fetch and official SDK use are available | `run()` preserves provider-native OpenAI protocol objects rather than Cloudflare's per-model post-processed outputs; model ids are `/` | Managed catalog/credentials, usage/quota, AI Gateway, `toMarkdown()`, async batch, background Responses, WebRTC, SIP | +| Service bindings | Supported | — | — | — | +| Platform bindings | Supported | A WDL addition with no Cloudflare counterpart: operator-curated capabilities injected into `env` via `[[platform_bindings]]` | — | — | +| Vars and secrets | Supported | — | Secrets are platform-managed via `wdl secret`, not Cloudflare account secrets | — | +| Cache API (`caches.default`) | Not supported | — | — | Not exposed; do not depend on it | +| Vectorize, Analytics Engine, Browser Rendering, Hyperdrive, Email | Not supported | — | — | No binding exists; deploy rejects these config sections loudly | Resources are platform-local, not Cloudflare account resources: `kv_namespaces.id`, queue names, and platform binding names refer to this diff --git a/README-zh.md b/README-zh.md index faf3ade..f9e4d8f 100644 --- a/README-zh.md +++ b/README-zh.md @@ -4,7 +4,7 @@ [English](https://github.com/wdl-dev/cli/blob/main/README.md) | 中文 -`wdl` 是 [**WDL 平台**](https://github.com/wdl-dev/wdl)(一套可自托管的运行时 + 控制面,让 Cloudflare Workers 风格的代码在 Cloudflare 之外运行)的配套 CLI:用 Wrangler v4 在本地打包项目、上传到运维方的控制面,并在你自己的命名空间里管理周边的一切——D1、R2、KV、Queues、Durable Objects、Workflows、secrets 和实时日志。 +`wdl` 是 [**WDL 平台**](https://github.com/wdl-dev/wdl)(一套可自托管的运行时 + 控制面,让 Cloudflare Workers 风格的代码在 Cloudflare 之外运行)的配套 CLI:用 Wrangler v4 在本地打包项目、上传到运维方的控制面,并在你自己的命名空间里管理周边的一切——D1、R2、KV、Queues、Durable Objects、Workflows、AI providers、secrets 和实时日志。 ## 与 Cloudflare Workers 的关系 @@ -30,6 +30,7 @@ WDL 首先是开源基础设施:运维方自建平台([wdl-dev/wdl](https:// - **部署** —— 本地 Wrangler v4 打包、manifest 校验、版本化上传 + promote; `[env.]` 环境覆盖。 - **资源** —— D1(SQL、迁移)、R2 对象、KV、Queue 生产/消费、Durable Objects、Workflows、CDN 静态资源。 +- **AI** —— OpenAI、xAI、DeepSeek 的 namespace BYO 凭据,以及 Responses、tools、SSE、官方 OpenAI SDK 调用和 WebSocket 推理。 - **Secrets** —— worker 级与命名空间级运行时密钥,从 stdin 读值,不进 shell 历史。 - **可观测** —— `wdl tail` 实时流式输出 console 与异常;`wdl workers` 列出部署状态。 - **诊断** —— `wdl doctor`、`wdl config explain`、`wdl whoami` 说清 CLI 解析出了什么、控制面看到了什么。 @@ -77,6 +78,7 @@ wdl token list [--json] / wdl token use / wdl token rm --ns wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... +wdl ai providers ... / wdl ai credential put ... / wdl ai models wdl delete worker [--dry-run] / wdl delete version wdl config explain / wdl doctor / wdl whoami [--json] wdl --version / wdl --help / wdl help @@ -89,7 +91,7 @@ wdl --version / wdl --help / wdl help | 位置 | 内容 | | --- | --- | | [GUIDE.md](https://github.com/wdl-dev/cli/blob/main/GUIDE.md) / [GUIDE-zh.md](https://github.com/wdl-dev/cli/blob/main/GUIDE-zh.md) | 完整租户手册:配置、部署、各类绑定、调试 | -| [docs/](https://github.com/wdl-dev/cli/blob/main/docs/README-zh.md) | 分功能参考(KV、D1、R2、queues、cron、DO、workflows、assets、环境覆盖、secrets),中英双语 | +| [docs/](https://github.com/wdl-dev/cli/blob/main/docs/README-zh.md) | 分功能参考(KV、D1、R2、AI、queues、cron、DO、workflows、assets、环境覆盖、secrets),中英双语 | | [examples/](https://github.com/wdl-dev/cli/tree/main/examples) | 每个功能一个可部署的最小项目 | | 需求 | 示例 | @@ -101,6 +103,7 @@ wdl --version / wdl --help / wdl help | Queue 生产 + 消费 | [`queues-demo`](https://github.com/wdl-dev/cli/tree/main/examples/queues-demo) | | Durable Object 计数器 | [`durable-objects-demo`](https://github.com/wdl-dev/cli/tree/main/examples/durable-objects-demo) | | Workflow 启动 / 状态 / 事件 | [`workflows-demo`](https://github.com/wdl-dev/cli/tree/main/examples/workflows-demo) | +| Responses function-tool Agent | [`ai-agent-demo`](https://github.com/wdl-dev/cli/tree/main/examples/ai-agent-demo) | | 静态资源 | [`pages-assets`](https://github.com/wdl-dev/cli/tree/main/examples/pages-assets) | | 环境覆盖与 worker 命名 | [`env-overrides-demo`](https://github.com/wdl-dev/cli/tree/main/examples/env-overrides-demo) | | R2 + D1 + KV + assets 组合 | [`inspection-demo`](https://github.com/wdl-dev/cli/tree/main/examples/inspection-demo) | @@ -133,7 +136,7 @@ Worker/项目目录名:[如果已知就填,例如 hello-counter;不知道 `wdl init && cd && npm install` (给 `wdl init` 加 `--ns ` 可把 namespace 烤进 deploy 脚本;否则部署期从 `wdl token` 默认或 `--ns` 解析。) 4. 立刻打开并阅读新目录里的 `AGENTS.md`,再根据我的功能打开 `node_modules/@wdl-dev/cli/docs/` 下相关文档和示例。注意:session 中新生成的 `AGENTS.md` 不会自动加载,必须显式读取。 -5. 根据功能修改 `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` 和 `src/`。需要第三方 API 鉴权 secret 时用 `wdl secret put --worker ` 写入,不要把 token 放进源码、Wrangler config 或 `.env`。 +5. 根据功能修改 `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` 和 `src/`。AI provider 凭据使用 `wdl ai credential put `;其它第三方 API secret 使用 `wdl secret put --worker `。不要把 token 放进源码、Wrangler config 或 `.env`。 6. 先跑 `npm run dry-run` 修复本地 bundle 问题,再跑 `npm run deploy` 部署。 7. 部署成功后给我 CLI 输出的 Worker URL(启用时的平台 URL,以及所有 active route-pattern URL hint)、本次改了哪些文件,以及我该如何验证。 ``` diff --git a/README.md b/README.md index 8f717f2..d999a0a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ English | [中文](https://github.com/wdl-dev/cli/blob/main/README-zh.md) control plane that runs Cloudflare Workers-style code outside Cloudflare. It bundles your project with Wrangler v4, uploads it to your operator's control plane, and manages everything around it — D1, R2, KV, Queues, Durable Objects, -Workflows, secrets, and live logs — inside your own namespace. +Workflows, AI providers, secrets, and live logs — inside your own namespace. ## How it relates to Cloudflare Workers @@ -59,6 +59,8 @@ it, email . uploads with promote; environment overrides via `[env.]`. - **Resources** — D1 (SQL, migrations), R2 objects, KV, Queue producers/consumers, Durable Objects, Workflows, static assets on a CDN. +- **AI** — namespace BYO credentials for OpenAI, xAI, and DeepSeek; Responses, + tools, SSE, official OpenAI SDK calls, and WebSocket inference. - **Secrets** — worker-level and namespace-level runtime secrets, set from stdin so values stay out of shell history. - **Observability** — `wdl tail` streams live console output and exceptions; @@ -119,6 +121,7 @@ wdl token list [--json] / wdl token use / wdl token rm --ns wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... +wdl ai providers ... / wdl ai credential put ... / wdl ai models wdl delete worker [--dry-run] / wdl delete version wdl config explain / wdl doctor / wdl whoami [--json] wdl --version / wdl --help / wdl help @@ -129,11 +132,11 @@ that has already verified the target. ## Documentation -| Where | What | -| ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| [GUIDE.md](https://github.com/wdl-dev/cli/blob/main/GUIDE.md) / [GUIDE-zh.md](https://github.com/wdl-dev/cli/blob/main/GUIDE-zh.md) | The full tenant manual: setup, deploy, every binding, debugging | -| [docs/](https://github.com/wdl-dev/cli/blob/main/docs/README.md) | Per-feature references (KV, D1, R2, queues, cron, DO, workflows, assets, env overrides, secrets) — bilingual, each page has a `-zh` twin | -| [examples/](https://github.com/wdl-dev/cli/tree/main/examples) | Minimal deployable projects, one per feature | +| Where | What | +| ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| [GUIDE.md](https://github.com/wdl-dev/cli/blob/main/GUIDE.md) / [GUIDE-zh.md](https://github.com/wdl-dev/cli/blob/main/GUIDE-zh.md) | The full tenant manual: setup, deploy, every binding, debugging | +| [docs/](https://github.com/wdl-dev/cli/blob/main/docs/README.md) | Per-feature references (KV, D1, R2, AI, queues, cron, DO, workflows, assets, env overrides, secrets) — bilingual, each page has a `-zh` twin | +| [examples/](https://github.com/wdl-dev/cli/tree/main/examples) | Minimal deployable projects, one per feature | | Need | Example | | -------------------------------- | ------------------------------------------------------------------------------------------------ | @@ -144,6 +147,7 @@ that has already verified the target. | Queue producer + consumer | [`queues-demo`](https://github.com/wdl-dev/cli/tree/main/examples/queues-demo) | | Durable Object counter | [`durable-objects-demo`](https://github.com/wdl-dev/cli/tree/main/examples/durable-objects-demo) | | Workflow start / status / events | [`workflows-demo`](https://github.com/wdl-dev/cli/tree/main/examples/workflows-demo) | +| Responses function-tool agent | [`ai-agent-demo`](https://github.com/wdl-dev/cli/tree/main/examples/ai-agent-demo) | | Static assets | [`pages-assets`](https://github.com/wdl-dev/cli/tree/main/examples/pages-assets) | | Env overrides & worker naming | [`env-overrides-demo`](https://github.com/wdl-dev/cli/tree/main/examples/env-overrides-demo) | | R2 + D1 + KV + assets combined | [`inspection-demo`](https://github.com/wdl-dev/cli/tree/main/examples/inspection-demo) | @@ -179,7 +183,7 @@ Steps: `wdl init && cd && npm install` (add `--ns ` to `wdl init` to bake the namespace into the deploy script; otherwise it resolves from the `wdl token` default or `--ns` at deploy time.) 4. Immediately open and read `AGENTS.md` in the new directory, then open the relevant docs and examples under `node_modules/@wdl-dev/cli/docs/` for my feature. Note: a freshly generated `AGENTS.md` is not loaded automatically mid-session — read it explicitly. -5. Edit `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` and `src/` for the feature. Push third-party API secrets with `wdl secret put --worker `; never put tokens in source, Wrangler config, or `.env`. +5. Edit `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` and `src/` for the feature. AI provider credentials use `wdl ai credential put `; other third-party API secrets use `wdl secret put --worker `. Never put tokens in source, Wrangler config, or `.env`. 6. Run `npm run dry-run` first and fix local bundle issues, then deploy with `npm run deploy`. 7. After a successful deploy, give me the Worker URL(s) printed by the CLI (the platform URL when enabled, plus any active route-pattern URL hints), the files you changed, and how I should verify. ``` diff --git a/bin/wdl.js b/bin/wdl.js index 1ced928..51ec879 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -10,6 +10,7 @@ import * as d1Cmd from "../commands/d1.js"; import * as r2Cmd from "../commands/r2.js"; import * as tailCmd from "../commands/tail.js"; import * as workflowsCmd from "../commands/workflows.js"; +import * as aiCmd from "../commands/ai.js"; import * as configCmd from "../commands/config.js"; import * as doctorCmd from "../commands/doctor.js"; import * as whoamiCmd from "../commands/whoami.js"; @@ -34,6 +35,7 @@ const REGISTRY = [ r2Cmd, tailCmd, workflowsCmd, + aiCmd, tokenCmd, configCmd, doctorCmd, @@ -168,7 +170,7 @@ function usage(exitCode) { write( formatHelp({ usage: ["wdl [args] [options]", "wdl --help", "wdl help ", "wdl --version"], - description: "Manage deployments, diagnostics, secrets, workers, D1, R2, and Workflows for a WDL control plane.", + description: "Manage deployments, diagnostics, secrets, workers, data services, AI, and Workflows for WDL.", commands: REGISTRY.map((c) => { const alias = aliasesByTarget[c.meta.name]; const note = alias ? ` (alias: ${alias.join(", ")})` : ""; diff --git a/commands/ai.js b/commands/ai.js new file mode 100644 index 0000000..03c7a69 --- /dev/null +++ b/commands/ai.js @@ -0,0 +1,256 @@ +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import path from "node:path"; + +import { defineCommand } from "../lib/command.js"; +import { + CliError, + defineCliOption, + formatHelp, + isMain, + isPathInside, + optionHelp, + unexpectedArgument, +} from "../lib/common.js"; +import { escapeTerminalText, writeJsonOr, writeStatusLine } from "../lib/output.js"; +import { confirmAction, readSecretStdin } from "../lib/stdin.js"; + +const AI_OPTIONS = [ + defineCliOption("file", { type: "string" }, "--file ", "Provider JSON file for providers put."), + defineCliOption("yes", { type: "boolean" }, "--yes", "Skip provider delete confirmation."), + "ns", + "control", + "json", + "help", +]; + +const command = defineCommand({ + name: "ai", + summary: "Manage AI providers, credentials, and models.", + options: AI_OPTIONS, + usage: usageText, + run: runAi, +}); + +export const main = command.main; +export const runAiCommand = command.run; +export const meta = command.meta; + +/** + * @typedef {import("../lib/command.js").PresetFlags<"ns" | "control" | "json"> & { + * file?: string, + * yes?: boolean, + * }} AiFlags + */ + +/** @param {{ values: AiFlags, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +async function runAi({ values, positionals, context }) { + const { stdout, stderr, stdin } = context; + const [group, action, provider] = positionals; + const extraArg = positionals[3]; + const ns = context.resolveNamespace(); + if (!group || !ns) throw new CliError(usageText()); + + if (group === "models") { + if (action) throw unexpectedArgument("ai models", action); + const { headers } = context.resolveControl(); + const body = /** @type {AiModelsResponse} */ ( + await context.fetchJson(context.nsUrl("ai", "models"), { headers }, "list AI models") + ); + if (writeJsonOr(values.json === true, body, stdout)) return; + const models = Array.isArray(body.models) ? body.models : []; + if (models.length === 0) { + writeStatusLine(stdout, "(no configured AI models)"); + return; + } + for (const model of models) { + const transports = Array.isArray(model.transports) ? model.transports.join(",") : "-"; + writeStatusLine( + stdout, + `${escapeTerminalText(String(model.id ?? "-"))} protocol=${escapeTerminalText(String(model.protocol ?? "-"))} transports=${escapeTerminalText(transports)}` + ); + } + return; + } + + if (group === "providers" && action === "list") { + if (provider) throw unexpectedArgument("ai providers list", provider); + const { headers } = context.resolveControl(); + const body = /** @type {AiProvidersResponse} */ ( + await context.fetchJson(context.nsUrl("ai", "providers"), { headers }, "list AI providers") + ); + if (writeJsonOr(values.json === true, body, stdout)) return; + const providers = Array.isArray(body.providers) ? body.providers : []; + if (providers.length === 0) { + writeStatusLine(stdout, "(no AI providers)"); + return; + } + for (const entry of providers) { + const modelCount = entry.models && typeof entry.models === "object" ? Object.keys(entry.models).length : 0; + writeStatusLine( + stdout, + `${escapeTerminalText(String(entry.name ?? "-"))} kind=${escapeTerminalText(String(entry.kind ?? "-"))} models=${modelCount} credential=${entry.credentialConfigured === true ? "configured" : "missing"}` + ); + } + return; + } + + if (group === "providers" && action === "get") { + requireProvider(provider, "ai providers get"); + if (extraArg) throw unexpectedArgument("ai providers get", extraArg); + const { headers } = context.resolveControl(); + const body = /** @type {AiProviderResponse} */ ( + await context.fetchJson(context.nsUrl("ai", "providers", provider), { headers }, "get AI provider") + ); + if (writeJsonOr(values.json === true, body, stdout)) return; + const entry = body.provider; + writeStatusLine(stdout, `name: ${escapeTerminalText(String(entry?.name ?? provider))}`); + writeStatusLine(stdout, `kind: ${escapeTerminalText(String(entry?.kind ?? "-"))}`); + writeStatusLine(stdout, `revision: ${escapeTerminalText(String(entry?.revision ?? "-"))}`); + writeStatusLine(stdout, `credential: ${entry?.credentialConfigured === true ? "configured" : "missing"}`); + for (const [alias, descriptor] of Object.entries(entry?.models ?? {})) { + const protocol = descriptor && typeof descriptor === "object" ? descriptor.protocol : undefined; + writeStatusLine(stdout, `model: ${escapeTerminalText(alias)} (${escapeTerminalText(String(protocol ?? "-"))})`); + } + return; + } + + if (group === "providers" && action === "put") { + requireProvider(provider, "ai providers put"); + if (extraArg) throw unexpectedArgument("ai providers put", extraArg); + const providerBody = readProviderFile(values.file, context.cwd); + const { headers } = context.resolveControl(); + const body = /** @type {AiProviderResponse} */ ( + await context.fetchJson( + context.nsUrl("ai", "providers", provider), + { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify(providerBody), + }, + "put AI provider" + ) + ); + if (writeJsonOr(values.json === true, body, stdout)) return; + writeStatusLine( + stdout, + `OK AI provider ${escapeTerminalText(provider)} saved; credential cleared, configure it before use` + ); + return; + } + + if (group === "providers" && action === "delete") { + requireProvider(provider, "ai providers delete"); + if (extraArg) throw unexpectedArgument("ai providers delete", extraArg); + const { headers } = context.resolveControl(); + await confirmAction({ + yes: values.yes === true, + stdin, + stderr, + prompt: `Are you sure you want to delete AI provider "${ns}/${provider}" and its credential? [y/N] `, + action: `delete AI provider "${ns}/${provider}"`, + }); + const body = /** @type {{ ok?: boolean, deleted?: boolean }} */ ( + await context.fetchJson( + context.nsUrl("ai", "providers", provider), + { method: "DELETE", headers }, + "delete AI provider" + ) + ); + if (writeJsonOr(values.json === true, body, stdout)) return; + writeStatusLine( + stdout, + body.deleted === true + ? `OK AI provider ${escapeTerminalText(provider)} and its credential deleted` + : `(AI provider ${escapeTerminalText(provider)} was not configured)` + ); + return; + } + + if (group === "credential" && action === "put") { + requireProvider(provider, "ai credential put"); + if (extraArg) throw unexpectedArgument("ai credential put", extraArg); + const { headers } = context.resolveControl(); + const providerBody = /** @type {AiProviderResponse} */ ( + await context.fetchJson(context.nsUrl("ai", "providers", provider), { headers }, "get AI provider") + ); + const revision = providerBody.provider?.revision; + if (typeof revision !== "string" || !revision) { + throw new CliError("AI provider response did not include a revision"); + } + const credential = await readSecretStdin(stdin, { + prompt: `Enter credential for ${ns}/${provider} (input hidden): `, + stderr, + }); + if (!credential) throw new CliError("AI credential must not be empty"); + const body = await context.fetchJson( + context.nsUrl("ai", "providers", provider, "credential"), + { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ revision, credential }), + }, + "put AI credential" + ); + if (writeJsonOr(values.json === true, body, stdout)) return; + writeStatusLine(stdout, `OK AI credential configured for ${escapeTerminalText(provider)}`); + return; + } + + throw new CliError(`unknown ai command: ${escapeTerminalText(positionals.join(" "))}\n${usageText()}`); +} + +/** @param {string | undefined} provider @param {string} commandName */ +function requireProvider(provider, commandName) { + if (!provider) throw new CliError(`${commandName} requires `); +} + +/** @param {string | undefined} file @param {string} cwd */ +function readProviderFile(file, cwd) { + if (typeof file !== "string" || !file) throw new CliError("ai providers put requires --file "); + if (!existsSync(cwd)) throw new CliError(`working directory ${escapeTerminalText(cwd)} does not exist`); + const root = realpathSync(cwd); + const candidate = path.resolve(root, file); + const resolved = existsSync(candidate) ? realpathSync(candidate) : candidate; + if (!isPathInside(root, resolved)) throw new CliError("--file must stay inside the project"); + let text; + try { + text = readFileSync(resolved, "utf8"); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); + throw new CliError(`cannot read AI provider file ${escapeTerminalText(file)}: ${escapeTerminalText(message)}`); + } + /** @type {unknown} */ + let parsed; + try { + parsed = JSON.parse(text); + } catch { + throw new CliError("AI provider file must contain valid JSON"); + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError("AI provider file must contain a JSON object"); + } + return parsed; +} + +/** @typedef {{ providers?: Array<{ name?: unknown, kind?: unknown, models?: unknown, credentialConfigured?: unknown }> }} AiProvidersResponse */ +/** @typedef {{ provider?: { name?: unknown, kind?: unknown, revision?: unknown, models?: Record, credentialConfigured?: unknown } }} AiProviderResponse */ +/** @typedef {{ models?: Array<{ id?: unknown, protocol?: unknown, transports?: unknown }> }} AiModelsResponse */ + +function usageText() { + return formatHelp({ + usage: [ + "wdl ai providers list [options]", + "wdl ai providers get [options] ", + "wdl ai providers put [options] --file ", + "wdl ai providers delete [options] [--yes]", + "wdl ai credential put [options] ", + "wdl ai models [options]", + ], + description: "Manage namespace-scoped AI provider metadata, credentials, and available models.", + options: optionHelp(AI_OPTIONS), + }); +} + +if (isMain(import.meta.url)) { + await main(); +} diff --git a/docs/README-zh.md b/docs/README-zh.md index d6aabca..be78f6b 100644 --- a/docs/README-zh.md +++ b/docs/README-zh.md @@ -19,6 +19,7 @@ | Durable Object 本地 class、SQLite-backed state | [durable-objects-zh.md](./durable-objects-zh.md) | | Promotion 的会话策略(`[wdl] session_policy`) | [deploy-zh.md](./deploy-zh.md) | | Workflow 实例、durable steps、事件等待 | [workflows-zh.md](./workflows-zh.md) | +| Agent 推理、provider 凭据、SSE、WebSocket | [ai-zh.md](./ai-zh.md) | | Queue producer / consumer 后台任务 | [queues-zh.md](./queues-zh.md) | | Cron / 定时任务 | [cron-triggers-zh.md](./cron-triggers-zh.md) | | 静态资源和 `env.ASSETS.url()` | [assets-zh.md](./assets-zh.md) | @@ -42,6 +43,7 @@ | Queue producer + consumer + KV | `queues-demo` | | Durable Object 计数器 | `durable-objects-demo` | | Workflow 启动 / 状态 / 事件 | `workflows-demo` | +| Responses function-tool Agent | `ai-agent-demo` | | 静态资源 | `pages-assets` | | WDL 环境覆盖与 worker 命名差异 | `env-overrides-demo` | | R2 + D1 + KV + assets 组合 | `inspection-demo` | diff --git a/docs/README.md b/docs/README.md index 36a07ef..3e56ce9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,6 +28,7 @@ English set. | Durable Object local classes, SQLite-backed state | [durable-objects.md](./durable-objects.md) | | Session policy on promotion (`[wdl] session_policy`) | [deploy.md](./deploy.md) | | Workflow instances, durable steps, event waits | [workflows.md](./workflows.md) | +| Agent inference, provider credentials, SSE, WebSocket | [ai.md](./ai.md) | | Queue producer / consumer background work | [queues.md](./queues.md) | | Cron / scheduled jobs | [cron-triggers.md](./cron-triggers.md) | | Static assets and `env.ASSETS.url()` | [assets.md](./assets.md) | @@ -56,6 +57,7 @@ closest example and rename it: | Queue producer + consumer + KV | `queues-demo` | | Durable Object counter | `durable-objects-demo` | | Workflow start / status / events | `workflows-demo` | +| Responses function-tool agent | `ai-agent-demo` | | Static assets | `pages-assets` | | WDL env overrides & worker naming | `env-overrides-demo` | | R2 + D1 + KV + assets combined | `inspection-demo` | diff --git a/docs/ai-zh.md b/docs/ai-zh.md new file mode 100644 index 0000000..d45adc7 --- /dev/null +++ b/docs/ai-zh.md @@ -0,0 +1,191 @@ +# AI Binding — Agent 推理 + +## 这是什么 + +WDL 提供命名空间级 AI binding,provider 凭据只保存在平台控制面中。租户代码可以使用 `env.AI.fetch()`、`env.AI.run()` 和 `env.AI.models()`,但不会拿到 provider API key。 + +首个版本面向 OpenAI、xAI 和 DeepSeek 官方 API。模型别名选择当前命名空间中配置的 provider 元数据;租户请求不能传入任意 provider endpoint 或认证 header。 + +在所选 provider 和模型描述声明支持时,WDL 保留 OpenAI Responses、Chat Completions、Embeddings、SSE、Responses WebSocket 和 Realtime WebSocket 协议形状。WDL 不替租户执行 function tool,也不会把 provider 特有响应字段压平成统一的最低公共形态。 + +## Wrangler 配置 + +声明一个 AI binding: + +```toml +[ai] +binding = "AI" +``` + +该表只接受 `binding`。provider 由每次调用的模型 id 选择,而不是写在 Wrangler 配置中。与其它资源 binding 一样,AI binding 按 environment 隔离。 + +## 配置 provider + +Provider 元数据和凭据是命名空间资源。即使命名空间暂时没有任何已部署 Worker,它们仍会保留,与 namespace secret 的生命周期一致。 + +在项目内创建 provider JSON 文件: + +```json +{ + "kind": "openai", + "models": { + "primary": { + "upstreamModel": "gpt-5", + "protocol": "responses", + "transports": ["http", "sse", "responses_websocket"], + "inputModalities": ["image", "text"], + "outputModalities": ["text"], + "capabilities": { + "functionTools": true, + "structuredOutput": true, + "reasoning": true, + "previousResponseId": true, + "providerTools": true, + "binaryFrames": false + } + } + } +} +``` + +然后分别写入元数据和凭据: + +```bash +wdl ai providers put openai --file provider.openai.json --ns +printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai --ns +wdl ai providers get openai --ns +wdl ai models --ns +``` + +`providers put` 会生成新的 provider revision 并清除旧凭据,防止变更后的目标或模型定义静默继承为旧 revision 批准的凭据。每次更新元数据后都要重新执行 `credential put`。凭据从隐藏 TTY 输入或 stdin 读取;CLI 不提供命令行 credential flag,list/get 也绝不返回凭据值。官方 provider credential 必须是不含空白的 visible-ASCII bearer token。 + +支持的 provider kind 与规范上游范围: + +| `kind` | HTTP 协议 | WebSocket 协议 | +| --- | --- | --- | +| `openai` | Responses、Chat Completions、Embeddings | Responses WebSocket、Realtime | +| `xai` | Responses、Chat Completions、Embeddings | Responses WebSocket、Realtime | +| `deepseek` | Responses 和 Chat Completions 兼容路径 | 首个版本不支持 | + +Provider `name` 与模型 alias 组成租户模型 id `/`,例如 `openai/primary`。`upstreamModel` 是 provider 原生模型 id,可以包含 provider 特有标点。 + +Provider 管理命令: + +```bash +wdl ai providers list [--json] +wdl ai providers get [--json] +wdl ai providers put --file [--json] +wdl ai credential put [--json] +wdl ai providers delete [--yes] [--json] +wdl ai models [--json] +``` + +删除 provider 会同时删除其凭据。CLI 有意不复制完整 descriptor grammar 和总量限制;Control 是唯一权威校验者。 + +## 使用 `run()` 构建 Agent + +`run(model, inputs, options?)` 注入已配置的上游模型并发送原生协议 body。Responses 示例: + +```js +const response = await env.AI.run("openai/primary", { + input: "Check the weather and call a tool if needed.", + tools: [ + { + type: "function", + name: "get_weather", + description: "Get weather for a city", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + additionalProperties: false, + }, + strict: true, + }, + ], + reasoning: { effort: "medium" }, +}); +``` + +租户负责校验 tool 参数、执行工具,并在后续 Responses 调用中提交 `function_call_output`。WDL 不自动执行工具,也不会自动跟随 response continuation。 + +流式调用返回 `ReadableStream`: + +```js +const stream = await env.AI.run("openai/primary", { + input: "Write a concise migration plan.", + stream: true, +}); + +for await (const chunk of stream) { + // 解析 provider 的语义 SSE event。 +} +``` + +取消使用 `options.signal`: + +```js +const controller = new AbortController(); +const pending = env.AI.run("openai/primary", { input: "..." }, { + signal: controller.signal, +}); +controller.abort(); +await pending; +``` + +`run()` option 只支持 `signal` 和 `websocket`。不支持的 Cloudflare option 会明确报错。需要原始 `Response` 时使用 `fetch()`;本实现有意不支持 `returnRawResponse`。 + +## Raw fetch 与 OpenAI SDK + +`env.AI.fetch()` 只接受虚拟 origin `https://ai.wdl` 及受支持的 `/v1/...` 路径。请求 body 携带 WDL 模型别名;host binding 解析官方目标并附加 provider 凭据。 + +```js +const response = await env.AI.fetch("https://ai.wdl/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "openai/primary", input: "Hello" }), +}); +``` + +官方 OpenAI JavaScript SDK 可用于 JSON、SSE 和取消:配置 `baseURL: "https://ai.wdl/v1"`、占位 `apiKey`,并设置 `fetch: env.AI.fetch.bind(env.AI)`。占位 key 只满足 SDK 自身校验;WDL 会移除调用方 authorization,并在 host binding 内附加已配置凭据。当前不承诺 SDK 的 WebSocket helper;WebSocket 应直接使用 binding 表面。 + +## WebSocket 推理 + +模型声明支持 `responses_websocket` 或 `realtime_websocket` 时: + +```js +const response = await env.AI.run("openai/realtime", null, { + websocket: true, + signal: request.signal, +}); +const socket = response.webSocket; +socket.accept(); +socket.send(JSON.stringify({ type: "session.update", session: {} })); +``` + +应用负责 provider 协议帧、重连和 close 处理。WDL 桥接文本/二进制帧及 provider close code,但不会恢复已中断的模型会话。在 Durable Object 中保持长连接会消耗 do-runtime 自己的 AI pool,并可能让 actor 保持活跃。 + +## 安全与运维边界 + +- Provider 凭据加密落盘,绝不进入 bundle metadata、生成源码、租户原始 env、日志或请求参数。 +- Provider kind 固定规范官方 endpoint;租户模型别名、请求 body 和 header 不能选择其它 host。 +- Provider 流量使用 runtime 专用的 public-only network binding。 +- 普通请求、流和 WebSocket 使用三个独立的 per-replica pool;饱和时立即失败。这是进程隔离,不是租户 quota 或计费策略。 +- 调用具有独立的 request/deadline、idle、frame、字节和总时长边界;清理不只依赖调用方断开信号。 +- Provider warning 和协议 payload 保持原生。`run()` 的稳定 WDL 错误使用 `AIError`;`fetch()` 返回 HTTP JSON error。 + +WDL 当前不提供托管模型凭据、持久用量统计、消费 quota、AI Gateway、异步 batch、`toMarkdown()`、background Responses/webhook、provider file API、WebRTC 或 SIP。 + +## 端到端示例 + +`../examples/ai-agent-demo` 展示 Responses function-tool 循环。写入 provider 文件、配置凭据并部署 Worker 后,再 POST prompt: + +```bash +cd examples/ai-agent-demo +wdl ai providers put openai --file provider.openai.json --ns +printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai --ns +wdl deploy . --ns +curl -X POST -H 'content-type: application/json' \ + -d '{"prompt":"What time is it in Asia/Tokyo?"}' \ + https://-ai-agent-demo./ +``` diff --git a/docs/ai.md b/docs/ai.md new file mode 100644 index 0000000..b277727 --- /dev/null +++ b/docs/ai.md @@ -0,0 +1,235 @@ +# AI Binding — Agent Inference + +## What it is + +WDL exposes a namespace-scoped AI binding backed by credentials that stay in the +platform control plane. Tenant code receives `env.AI.fetch()`, `env.AI.run()`, +and `env.AI.models()` without receiving provider API keys. + +The first release targets the official OpenAI, xAI, and DeepSeek APIs. Model +aliases select provider metadata configured for the current namespace; tenant +requests cannot supply arbitrary provider endpoints or authentication headers. + +WDL preserves OpenAI Responses, Chat Completions, Embeddings, SSE, Responses +WebSocket, and Realtime WebSocket protocol shapes where the selected provider +and model descriptor advertise them. It does not execute function tools for the +tenant or normalize provider-specific response fields. + +## Wrangler configuration + +Declare one AI binding: + +```toml +[ai] +binding = "AI" +``` + +The table accepts only `binding`. Provider selection is made by the model id on +each call, not in Wrangler config. The binding is environment-scoped like other +resource bindings. + +## Configure a provider + +Provider metadata and credentials are namespace resources. They remain after the +namespace has zero deployed Workers, matching namespace-secret lifecycle. + +Create a project-local provider JSON file: + +```json +{ + "kind": "openai", + "models": { + "primary": { + "upstreamModel": "gpt-5", + "protocol": "responses", + "transports": ["http", "sse", "responses_websocket"], + "inputModalities": ["image", "text"], + "outputModalities": ["text"], + "capabilities": { + "functionTools": true, + "structuredOutput": true, + "reasoning": true, + "previousResponseId": true, + "providerTools": true, + "binaryFrames": false + } + } + } +} +``` + +Then write metadata and its credential separately: + +```bash +wdl ai providers put openai --file provider.openai.json --ns +printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai --ns +wdl ai providers get openai --ns +wdl ai models --ns +``` + +`providers put` creates a new provider revision and clears any prior credential. +This prevents a changed destination/model definition from silently inheriting a +credential approved for an older revision. Run `credential put` again after +every metadata update. Credential input is read from hidden TTY input or stdin; +there is no command-line credential flag and credential values are never +returned by list/get commands. Official provider credentials must be +visible-ASCII bearer tokens without whitespace. + +Supported provider kinds and canonical upstream ownership: + +| `kind` | HTTP protocols | WebSocket protocols | +| ---------- | -------------------------------------------------- | ---------------------------------- | +| `openai` | Responses, Chat Completions, Embeddings | Responses WebSocket, Realtime | +| `xai` | Responses, Chat Completions, Embeddings | Responses WebSocket, Realtime | +| `deepseek` | Responses and Chat Completions compatibility paths | Not available in the first release | + +The provider `name` and each model alias form the tenant model id +`/`, for example `openai/primary`. `upstreamModel` is the +provider's native model id and may use provider-specific punctuation. + +Provider management commands: + +```bash +wdl ai providers list [--json] +wdl ai providers get [--json] +wdl ai providers put --file [--json] +wdl ai credential put [--json] +wdl ai providers delete [--yes] [--json] +wdl ai models [--json] +``` + +Deleting a provider also deletes its credential. The CLI intentionally leaves +the complete descriptor grammar and aggregate limits to Control, which is the +canonical validator. + +## Agent calls with `run()` + +`run(model, inputs, options?)` injects the configured upstream model and sends +the native protocol body. For Responses: + +```js +const response = await env.AI.run("openai/primary", { + input: "Check the weather and call a tool if needed.", + tools: [ + { + type: "function", + name: "get_weather", + description: "Get weather for a city", + parameters: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + additionalProperties: false, + }, + strict: true, + }, + ], + reasoning: { effort: "medium" }, +}); +``` + +The tenant validates tool arguments, executes the tool, and sends +`function_call_output` in a later Responses call. WDL never executes tools or +automatically follows a response continuation. + +Streaming returns the response body as a `ReadableStream`: + +```js +const stream = await env.AI.run("openai/primary", { + input: "Write a concise migration plan.", + stream: true, +}); + +for await (const chunk of stream) { + // Parse the provider's semantic SSE events. +} +``` + +Cancellation uses `options.signal`: + +```js +const controller = new AbortController(); +const pending = env.AI.run("openai/primary", { input: "..." }, { + signal: controller.signal, +}); +controller.abort(); +await pending; +``` + +The only `run()` options are `signal` and `websocket`. Unsupported Cloudflare +options fail loudly. Use `fetch()` when the application needs the raw +`Response`; `returnRawResponse` is intentionally not implemented. + +## Raw fetch and the OpenAI SDK + +`env.AI.fetch()` accepts only the virtual origin `https://ai.wdl` and the +supported `/v1/...` paths. The request body carries a WDL model alias; the host +binding resolves the official destination and attaches the provider credential. + +```js +const response = await env.AI.fetch("https://ai.wdl/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "openai/primary", input: "Hello" }), +}); +``` + +The official OpenAI JavaScript SDK works for JSON, SSE, and cancellation when +configured with `baseURL: "https://ai.wdl/v1"`, a placeholder `apiKey`, and +`fetch: env.AI.fetch.bind(env.AI)`. The placeholder satisfies SDK validation; +WDL strips caller authorization and attaches the configured credential inside +the host binding. SDK WebSocket helpers are not claimed; use the binding's +WebSocket surface directly. + +## WebSocket inference + +For a model that advertises `responses_websocket` or `realtime_websocket`: + +```js +const response = await env.AI.run("openai/realtime", null, { + websocket: true, + signal: request.signal, +}); +const socket = response.webSocket; +socket.accept(); +socket.send(JSON.stringify({ type: "session.update", session: {} })); +``` + +The application owns provider protocol frames, reconnection, and close handling. +WDL bridges text/binary frames and provider close codes but does not resume an +interrupted model session. Long-lived AI sockets in a Durable Object consume the +do-runtime AI pool and can keep that actor active. + +## Security and operational boundaries + +- Provider credentials are encrypted at rest and never enter bundle metadata, + generated source, raw tenant env, logs, or request arguments. +- Provider kind fixes the canonical official endpoint. Tenant model aliases, + request bodies, and headers cannot select another host. +- Provider traffic uses the runtime's dedicated public-only network binding. +- Request, streaming, and WebSocket concurrency use separate per-replica pools. + Saturation fails immediately; it is process isolation, not tenant quota or + billing policy. +- Calls have independent request/deadline, idle, frame, byte, and duration + bounds. Caller disconnect is not the only cleanup signal. +- Provider warnings and protocol payloads remain provider-native. Stable WDL + errors use `AIError` from `run()` or an HTTP JSON error from `fetch()`. + +WDL does not yet provide managed model credentials, durable usage accounting, +spend quotas, AI Gateway, asynchronous batch, `toMarkdown()`, background +Responses/webhooks, provider file APIs, WebRTC, or SIP. + +## End-to-end example + +`../examples/ai-agent-demo` demonstrates a Responses function-tool loop. Put the +provider file, configure its credential, deploy the Worker, then POST a prompt: + +```bash +cd examples/ai-agent-demo +wdl ai providers put openai --file provider.openai.json --ns +printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai --ns +wdl deploy . --ns +curl -X POST -H 'content-type: application/json' \ + -d '{"prompt":"What time is it in Asia/Tokyo?"}' \ + https://-ai-agent-demo./ +``` diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index a8302e4..ee47758 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -81,6 +81,7 @@ Cloudflare 用 `workers_dev` 控制 Worker 的 `*.workers.dev` route;版本化 - `[[d1_databases]]` → 对每个 `database_name`,先 `wdl d1 list` 检查;缺的用 `wdl d1 create ` 创建。见 [d1-zh.md](./d1-zh.md)。 - `[[r2_buckets]]` 和 `[[kv_namespaces]]` 是惰性的 —— 不需要预创建;首次使用时绑定即生效。见 [r2-zh.md](./r2-zh.md) 和 [kv-zh.md](./kv-zh.md)。 - `[[queues.*]]` —— 见 [queues-zh.md](./queues-zh.md);不确定队列归属时再找运维方确认。 + - `[ai]` —— 测试推理前先用 `wdl ai` 配置 provider 元数据和凭据。见 [ai-zh.md](./ai-zh.md)。 6. **应用 D1 迁移**,如果设置了 `migrations_dir` —— 见 [d1-zh.md](./d1-zh.md)。 7. **部署:** `wdl deploy .`。CLI 会打印上传、提升、运行时 URL —— 把这个 URL 给用户看。 @@ -122,9 +123,9 @@ wdl deploy . --env production 新项目应继续使用 `2026-06-17` compatibility date,除非具体功能需要更新日期。Control 会拒绝早于 `2026-04-01` 的显式日期、无效或未来日期,以及超出 bundled workerd 支持范围的日期。上游 experimental enable flags、`legacy_error_serialization` 和 `allow_irrevocable_stub_storage` 不受支持。 -**支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`route` / `routes`、`workers_dev`、`[wdl] session_policy`、`[env.]`。 +**支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[ai]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`route` / `routes`、`workers_dev`、`[wdl] session_policy`、`[env.]`。 -WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]` 本身,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。`[wdl] session_policy` 见上面的会话策略一节。 +WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns`、`[ai]` 和 `[wdl]` 本身,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。`[wdl] session_policy` 见上面的会话策略一节。 WDL 还会拒绝 Cloudflare Artifacts `triggers.events` subscription 和 R2 `local_dev.experimental_s3_credentials`;这两个字段都没有对应的 WDL deploy manifest 或 runtime 映射。 diff --git a/docs/deploy.md b/docs/deploy.md index ffa6c45..efe061c 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -149,6 +149,8 @@ changes only the control socket target and never a printed Worker origin. the binding works on first use. See [r2.md](./r2.md) and [kv.md](./kv.md). - `[[queues.*]]` — see [queues.md](./queues.md); when queue ownership is unclear, confirm with the operator. + - `[ai]` — configure provider metadata and its credential with `wdl ai` + before testing inference. See [ai.md](./ai.md). 6. **Apply D1 migrations** if `migrations_dir` is set — see [d1.md](./d1.md). 7. **Deploy:** `wdl deploy .`. The CLI prints the upload, the promote, and the runtime URL — show that URL to the user. @@ -231,18 +233,18 @@ control. Upstream experimental enable flags, `legacy_error_serialization`, and **Supported:** `name`, `main`, `compatibility_date` / `compatibility_flags`, `[vars]`, `[[kv_namespaces]]`, `[[d1_databases]]`, -`[[durable_objects.bindings]]`, `[[workflows]]`, `[[r2_buckets]]`, +`[[durable_objects.bindings]]`, `[[workflows]]`, `[[r2_buckets]]`, `[ai]`, `[assets] directory`, `[triggers] crons`, `[[triggers.schedules]]` (with timezone, a platform extension), `[[queues.producers]]` / `[[queues.consumers]]`, `[[services]]`, `[[platform_bindings]]`, `[[exports]]`, `route` / `routes`, `workers_dev`, `[wdl] session_policy`, `[env.]`. -WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and -`[[services]].ns` plus `[wdl]` itself and removes these private extensions from -the temporary config passed to the Wrangler bundler. Other fields retain their -existing Wrangler passthrough behavior. Wrangler's object-shaped declarative -`exports` configuration is not supported by WDL. `[wdl] session_policy` has its -own section above. +WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, +`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes these private +extensions from the temporary config passed to the Wrangler bundler. Other +fields retain their existing Wrangler passthrough behavior. Wrangler's +object-shaped declarative `exports` configuration is not supported by WDL. +`[wdl] session_policy` has its own section above. WDL also rejects Cloudflare Artifacts `triggers.events` subscriptions and R2 `local_dev.experimental_s3_credentials`: neither field has a WDL deploy-manifest diff --git a/docs/env-overrides-zh.md b/docs/env-overrides-zh.md index 72e0be7..1b4011c 100644 --- a/docs/env-overrides-zh.md +++ b/docs/env-overrides-zh.md @@ -75,7 +75,7 @@ wdl deploy . --env production Cloudflare Workers / Wrangler 的 `--env preview` 通常会发布带环境后缀的 worker / script 名。WDL 不会这样做:`wdl deploy . --env preview` 和 `wdl deploy . --env production` 都更新顶层 `name` 指定的同一个 worker。要部署两个独立 worker,请用两个不同的顶层 `name`、两个目录,或两个 namespace。 -`vars` 和大部分 bindings 仍按 Wrangler 的 non-inheritable 心智模型处理:选中 `[env.]` 后,顶层 `[vars]`、KV、D1、R2、queues、services、workflows 等不会自动继承到该 env。需要某个 runtime env 变量或 binding 时,要在对应的 `[env.]` 里重新声明。 +`vars` 和大部分 bindings 仍按 Wrangler 的 non-inheritable 心智模型处理:选中 `[env.]` 后,顶层 `[vars]`、KV、D1、R2、queues、services、workflows、AI 等不会自动继承到该 env。需要某个 runtime env 变量或 binding 时,要在对应的 `[env.]` 里重新声明。 按上面的例子: @@ -95,7 +95,7 @@ Cloudflare Workers / Wrangler 的 `--env preview` 通常会发布带环境后缀 `[env.]` 可以覆盖多类配置,但继承规则不同: -- Non-inheritable:`[env.].vars`、`[[env..kv_namespaces]]`、`[[env..d1_databases]]`、`[[env..r2_buckets]]`、`[[env..queues.*]]`、`[[env..services]]`、`[[env..workflows]]` 等。选中 env 后,顶层同类配置不会回退进来。 +- Non-inheritable:`[env.].vars`、`[[env..kv_namespaces]]`、`[[env..d1_databases]]`、`[[env..r2_buckets]]`、`[[env..queues.*]]`、`[[env..services]]`、`[[env..workflows]]`、`[env..ai]` 等。选中 env 后,顶层同类配置不会回退进来。 - Inheritable:`main`、`compatibility_date` / `compatibility_flags`、`route` / `routes`、`workers_dev`、`[wdl]`、`[[migrations]]`、`[assets]`、`[triggers]` 等。env 里没写时继续使用顶层值;env 里写了则覆盖顶层值。 因此,共享的 `vars` 或 binding 不能只放顶层后期待所有 env 自动继承;每个 env 都需要声明自己要用的 runtime vars 和 bindings。共享的 DO migrations、assets / cron 等可放顶层,只在差异 env 下覆盖。 diff --git a/docs/env-overrides.md b/docs/env-overrides.md index 6894b20..d1275ce 100644 --- a/docs/env-overrides.md +++ b/docs/env-overrides.md @@ -89,7 +89,7 @@ use two different top-level `name` values, two directories, or two namespaces. `vars` and most bindings still follow Wrangler's non-inheritable mental model: once `[env.]` is selected, top-level `[vars]`, KV, D1, R2, queues, -services, workflows, etc. do not inherit into that env automatically. When a +services, workflows, AI, etc. do not inherit into that env automatically. When a runtime env var or binding is needed, redeclare it inside the matching `[env.]`. @@ -125,8 +125,8 @@ differ: - Non-inheritable: `[env.].vars`, `[[env..kv_namespaces]]`, `[[env..d1_databases]]`, `[[env..r2_buckets]]`, `[[env..queues.*]]`, `[[env..services]]`, - `[[env..workflows]]`, etc. Once an env is selected, top-level config of - the same kind does not fall back in. + `[[env..workflows]]`, `[env..ai]`, etc. Once an env is selected, + top-level config of the same kind does not fall back in. - Inheritable: `main`, `compatibility_date` / `compatibility_flags`, `route` / `routes`, `workers_dev`, `[wdl]`, `[[migrations]]`, `[assets]`, `[triggers]`, etc. When the env does not set them, the top-level value keeps applying; when diff --git a/examples/ai-agent-demo/package.json b/examples/ai-agent-demo/package.json new file mode 100644 index 0000000..5bb2df2 --- /dev/null +++ b/examples/ai-agent-demo/package.json @@ -0,0 +1,8 @@ +{ + "name": "ai-agent-demo", + "private": true, + "type": "module", + "devDependencies": { + "wrangler": "^4.100.0" + } +} diff --git a/examples/ai-agent-demo/provider.openai.json b/examples/ai-agent-demo/provider.openai.json new file mode 100644 index 0000000..5e6b56e --- /dev/null +++ b/examples/ai-agent-demo/provider.openai.json @@ -0,0 +1,20 @@ +{ + "kind": "openai", + "models": { + "primary": { + "upstreamModel": "gpt-5", + "protocol": "responses", + "transports": ["http", "sse", "responses_websocket"], + "inputModalities": ["image", "text"], + "outputModalities": ["text"], + "capabilities": { + "functionTools": true, + "structuredOutput": true, + "reasoning": true, + "previousResponseId": true, + "providerTools": true, + "binaryFrames": false + } + } + } +} diff --git a/examples/ai-agent-demo/src/index.js b/examples/ai-agent-demo/src/index.js new file mode 100644 index 0000000..b89d494 --- /dev/null +++ b/examples/ai-agent-demo/src/index.js @@ -0,0 +1,82 @@ +const TIME_TOOL = { + type: "function", + name: "get_time", + description: "Get the current time in an IANA timezone", + parameters: { + type: "object", + properties: { + timezone: { type: "string" }, + }, + required: ["timezone"], + additionalProperties: false, + }, + strict: true, +}; + +function json(value, init = {}) { + const headers = new Headers(init.headers); + headers.set("content-type", "application/json; charset=utf-8"); + return new Response(JSON.stringify(value), { ...init, headers }); +} + +function responseText(response) { + return (response.output || []) + .flatMap((item) => item.content || []) + .filter((item) => item.type === "output_text") + .map((item) => item.text) + .join(""); +} + +function executeTool(call) { + if (call.name !== "get_time") throw new Error(`unsupported tool: ${call.name}`); + const args = JSON.parse(call.arguments); + const value = new Intl.DateTimeFormat("en-GB", { + dateStyle: "full", + timeStyle: "long", + timeZone: args.timezone, + }).format(new Date()); + return { type: "function_call_output", call_id: call.call_id, output: JSON.stringify({ value }) }; +} + +async function runAgent(ai, prompt) { + const first = await ai.run("openai/primary", { + input: prompt, + tools: [TIME_TOOL], + tool_choice: "auto", + reasoning: { effort: "low" }, + }); + const calls = (first.output || []).filter((item) => item.type === "function_call"); + if (calls.length === 0) return first; + return await ai.run("openai/primary", { + previous_response_id: first.id, + input: calls.map(executeTool), + tools: [TIME_TOOL], + }); +} + +export default { + async fetch(request, env) { + if (request.method === "GET") { + return json({ + worker: "ai-agent-demo", + usage: "POST JSON { prompt }", + models: await env.AI.models(), + }); + } + if (request.method !== "POST") return json({ error: "method_not_allowed" }, { status: 405 }); + const body = await request.json(); + const prompt = typeof body.prompt === "string" ? body.prompt : "What time is it in Asia/Tokyo?"; + try { + const response = await runAgent(env.AI, prompt); + return json({ id: response.id, text: responseText(response), response }); + } catch (error) { + return json( + { + error: error instanceof Error ? error.name : "Error", + message: error instanceof Error ? error.message : String(error), + }, + { status: 502 } + ); + } + }, +}; diff --git a/examples/ai-agent-demo/wrangler.toml b/examples/ai-agent-demo/wrangler.toml new file mode 100644 index 0000000..176b3b1 --- /dev/null +++ b/examples/ai-agent-demo/wrangler.toml @@ -0,0 +1,6 @@ +name = "ai-agent-demo" +main = "src/index.js" +compatibility_date = "2026-08-11" + +[ai] +binding = "AI" diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 87eec66..e8b6451 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -11,6 +11,7 @@ import { RESERVED_OBJECT_KEYS, WDL_RESERVED_BINDING_RE } from "./ns-pattern.js"; import { collectAssets, resolveAssetsDir } from "./wrangler/assets.js"; import { parseD1DatabasesFromCfg, + parseAiBindingFromCfg, parseDurableObjectsFromCfg, parseExportsFromCfg, parseKvNamespacesFromCfg, @@ -46,6 +47,7 @@ import { asRecord, hasOwn, manifestMap } from "./wrangler/utils.js"; // directly when it does not need the full packWranglerProject orchestration. export { collectAssets, MAX_ASSET_FILE_BYTES, MAX_ASSETS_TOTAL_BYTES, resolveAssetsDir } from "./wrangler/assets.js"; export { + parseAiBindingFromCfg, parseD1DatabasesFromCfg, parseDurableObjectsFromCfg, parseExportsFromCfg, @@ -194,6 +196,12 @@ export async function packWranglerProject({ bindings[r2.binding] = { type: "r2", bucketName: r2.bucketName }; } + const ai = wrapCli(() => parseAiBindingFromCfg(cfg, configRel)); + if (ai) { + claimBinding(ai.binding); + bindings[ai.binding] = { type: "ai" }; + } + const svcList = wrapCli(() => parseServicesFromCfg(cfg, configRel)); for (const svc of svcList) { claimBinding(svc.binding); diff --git a/lib/wrangler/bindings.js b/lib/wrangler/bindings.js index 3bc1082..b3ad972 100644 --- a/lib/wrangler/bindings.js +++ b/lib/wrangler/bindings.js @@ -376,6 +376,29 @@ export function parseR2BucketsFromCfg(cfg, configRel = "wrangler config") { return out; } +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {{ binding: string } | null} + */ +export function parseAiBindingFromCfg(cfg, configRel = "wrangler config") { + configRel = formatConfigRel(configRel); + if (cfg.ai === undefined) return null; + const ai = asRecord(cfg.ai); + if (!ai) throw new Error(`${configRel}: [ai] must be a table`); + const unknownKeys = Object.keys(ai).filter((key) => key !== "binding"); + if (unknownKeys.length > 0) { + throw new Error(`${configRel}: [ai] contains unsupported field(s): ${formatConfigKeyList(unknownKeys)}`); + } + if (typeof ai.binding !== "string" || !ai.binding.trim()) { + throw new Error(`${configRel}: [ai].binding is required`); + } + const binding = ai.binding.trim(); + assertNotRuntimeReservedBinding(configRel, "[ai]", binding); + assertValidBindingName(configRel, "[ai]", binding); + return { binding }; +} + /** * `binding` and `service` are validated as non-empty strings; `entrypoint` and * `ns`, when present, are validated as a JS identifier / admin-acceptable diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 63eed55..5ea3d4b 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -11,7 +11,7 @@ import { asRecord } from "./utils.js"; * parser re-validates the value it reads. Known sections (`name`, `main`, * `kv_namespaces`, `d1_databases`, `r2_buckets`, `services`, * `durable_objects`, `migrations`, `workflows`, `queues`, `exports`, - * `platform_bindings`, `wdl`, `vars`, `triggers`, `route`, `routes`, + * `platform_bindings`, `ai`, `wdl`, `vars`, `triggers`, `route`, `routes`, * `workers_dev`, `assets`, `compatibility_date`, `compatibility_flags`, `env`, * and the unsupported * sections rejected by name) are read off this object and narrowed at the use @@ -29,7 +29,6 @@ const TOP_LEVEL_ONLY_ENV_KEYS = new Set(["name", "keep_vars", "send_metrics"]); const UNSUPPORTED_WRANGLER_KEYS = [ "addresses", "agent_memory", - "ai", "ai_search", "ai_search_namespaces", "analytics_engine_datasets", @@ -82,7 +81,7 @@ const UNSUPPORTED_WRANGLER_KEYS = [ const SUPPORTED_WRANGLER_SUMMARY = "Supported: [[kv_namespaces]], [[d1_databases]], [[r2_buckets]], [[services]], " + "[[durable_objects.bindings]], [[workflows]], [[queues.producers]], [[queues.consumers]], " + - "[[platform_bindings]], [[exports]], [wdl], [vars], [triggers] crons, [[triggers.schedules]], " + + "[[platform_bindings]], [[exports]], [ai], [wdl], [vars], [triggers] crons, [[triggers.schedules]], " + "assets.directory, route(s), " + "workers_dev, compatibility_date/compatibility_flags."; @@ -95,6 +94,7 @@ const NON_INHERITABLE_ENV_KEYS = new Set([ "durable_objects", "kv_namespaces", "r2_buckets", + "ai", "ai_search_namespaces", "ai_search", "vectorize", @@ -198,6 +198,7 @@ function stripWdlConfigExtensions(cfg) { const projected = { ...cfg }; delete projected.exports; delete projected.platform_bindings; + delete projected.ai; delete projected.wdl; const triggers = asRecord(cfg.triggers); diff --git a/templates/AGENTS.md b/templates/AGENTS.md index ec8afb7..f1c2a78 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -28,6 +28,7 @@ package. | Object storage | `r2.md` | | Async queues / a queue handler | `queues.md` | | Workflows | `workflows.md` | +| AI agent inference / provider credentials | `ai.md` | | Scheduled / cron jobs | `cron-triggers.md` | | WDL environment override rules (preview / production) | `env-overrides.md` | | Runtime secrets | `secrets.md` | @@ -60,6 +61,8 @@ includes the platform-domain URL only while it is enabled. Cloudflare's separate `preview_urls` field is unsupported and rejected by the CLI. Cloudflare Artifacts `triggers.events` subscriptions and R2 `local_dev.experimental_s3_credentials` are also unsupported and rejected. +`[ai] binding = "AI"` declares the AI facade; provider metadata and credentials +are namespace resources managed with `wdl ai`, never ordinary Worker secrets. `[wdl] session_policy = "restart"` makes every promotion close the Worker's open WebSockets with `1012` and retire stale Durable Object facets on their next dispatch; the default `preserve` leaves facets on the version that built them @@ -78,6 +81,7 @@ When a snippet is not enough and you need a complete working file tree: | Queue producer + consumer + KV | `queues-demo` | | Durable Object counter | `durable-objects-demo` | | Workflow start / status / events | `workflows-demo` | +| Responses function-tool agent | `ai-agent-demo` | | Static assets | `pages-assets` | | WDL env overrides & worker naming | `env-overrides-demo` | | R2 + D1 + KV + assets combined | `inspection-demo` | @@ -85,9 +89,10 @@ When a snippet is not enough and you need a complete working file tree: ## Project-level anti-patterns - ❌ Hardcoding third-party API tokens or keys into code, `.env`, or Wrangler - config. Push them with `wdl secret put --worker ` — the secret - value is read from stdin (type it interactively, or pipe / redirect it in, - e.g. `printf '%s' "$VALUE" | wdl secret put --worker `); it is + config. AI provider keys use `wdl ai credential put `; other APIs + use `wdl secret put --worker ` — the secret value is read from + stdin (type it interactively, or pipe / redirect it in, e.g. + `printf '%s' "$VALUE" | wdl secret put --worker `); it is deliberately not a command-line argument so it stays out of shell history. - ❌ Testing platform bindings with `wrangler dev` — `[[platform_bindings]]` never resolves in any local runtime; the binding is `undefined` locally and @@ -120,9 +125,9 @@ Wrangler in two key ways: named `my-worker` deployed with `--env production` is still `my-worker` on WDL, where standard Cloudflare Workers / Wrangler would typically produce `my-worker-production`. -- `vars`, KV, D1, R2, Durable Objects, queues, services, workflows, and the like - are env-scoped / non-inheritable — top-level config of the same kind does not - flow into the selected env; redeclare it inside the `env.` block. +- `vars`, KV, D1, R2, AI, Durable Objects, queues, services, workflows, and the + like are env-scoped / non-inheritable — top-level config of the same kind does + not flow into the selected env; redeclare it inside the `env.` block. Full rules are in `env-overrides.md`. diff --git a/tests/unit/cli-ai.test.js b/tests/unit/cli-ai.test.js new file mode 100644 index 0000000..cf212d1 --- /dev/null +++ b/tests/unit/cli-ai.test.js @@ -0,0 +1,245 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { Readable } from "node:stream"; +import { test } from "node:test"; + +import { runAiCommand } from "../../commands/ai.js"; +import { mockDeps, response } from "./helpers.js"; + +/** @typedef {import("./helpers.js").ControlCall} ControlCall */ + +const PROVIDER = { + kind: "openai", + models: { + primary: { + upstreamModel: "gpt-5", + protocol: "responses", + transports: ["http", "sse"], + }, + }, +}; + +test("ai providers list and models render bounded summaries", async () => { + /** @type {string[]} */ + const providerLines = []; + await runAiCommand(["providers", "list", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => providerLines.push(line), + controlFetch: async () => + response({ + providers: [{ name: "openai", kind: "openai", models: PROVIDER.models, credentialConfigured: true }], + }), + }); + assert.deepEqual(providerLines, ["openai kind=openai models=1 credential=configured"]); + + /** @type {string[]} */ + const modelLines = []; + await runAiCommand(["models", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => modelLines.push(line), + controlFetch: async () => + response({ models: [{ id: "openai/primary", protocol: "responses", transports: ["http", "sse"] }] }), + }); + assert.deepEqual(modelLines, ["openai/primary protocol=responses transports=http,sse"]); +}); + +test("ai providers list and models support JSON and empty output", async () => { + /** @type {string[]} */ + const jsonLines = []; + const body = { providers: [] }; + await runAiCommand(["providers", "list", "--json", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => jsonLines.push(line), + controlFetch: async () => response(body), + }); + assert.deepEqual(jsonLines, [JSON.stringify(body, null, 2)]); + + /** @type {string[]} */ + const emptyLines = []; + await runAiCommand(["models", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => emptyLines.push(line), + controlFetch: async () => response({ models: [] }), + }); + assert.deepEqual(emptyLines, ["(no configured AI models)"]); +}); + +test("ai providers get encodes path segments and prints credential state", async () => { + /** @type {ControlCall[]} */ + const calls = []; + /** @type {string[]} */ + const lines = []; + await runAiCommand(["providers", "get", "provider/name", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({ + provider: { + name: "provider/name", + revision: "0123456789abcdef0123456789abcdef", + kind: "openai", + models: PROVIDER.models, + credentialConfigured: false, + }, + }); + }, + }); + assert.equal(calls[0].url, "http://ctl.test/ns/demo/ai/providers/provider%2Fname"); + assert.deepEqual(lines, [ + "name: provider/name", + "kind: openai", + "revision: 0123456789abcdef0123456789abcdef", + "credential: missing", + "model: primary (responses)", + ]); +}); + +test("ai providers put reads project-local JSON and warns that credentials were cleared", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-ai-provider-")); + try { + writeFileSync(path.join(dir, "provider.json"), JSON.stringify(PROVIDER)); + /** @type {ControlCall[]} */ + const calls = []; + /** @type {string[]} */ + const lines = []; + await runAiCommand( + ["providers", "put", "openai", "--file", "provider.json", "--ns", "demo", "--control-url", "http://ctl.test"], + { + cwd: dir, + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return response({ provider: { name: "openai", revision: "0".repeat(32), ...PROVIDER } }); + }, + } + ); + assert.equal(calls[0].url, "http://ctl.test/ns/demo/ai/providers/openai"); + assert.equal(calls[0].init.method, "PUT"); + assert.deepEqual(JSON.parse(/** @type {string} */ (calls[0].init.body)), PROVIDER); + assert.deepEqual(lines, ["OK AI provider openai saved; credential cleared, configure it before use"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("ai providers put rejects invalid or out-of-project files before control", async () => { + const parent = mkdtempSync(path.join(tmpdir(), "wdl-ai-provider-files-")); + const dir = path.join(parent, "project"); + mkdirSync(dir); + writeFileSync(path.join(dir, "bad.json"), "["); + writeFileSync(path.join(parent, "outside.json"), JSON.stringify(PROVIDER)); + try { + let calls = 0; + const deps = { + cwd: dir, + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => { + calls += 1; + return response({}); + }, + }; + await assert.rejects( + runAiCommand( + ["providers", "put", "openai", "--file", "bad.json", "--ns", "demo", "--control-url", "http://ctl.test"], + deps + ), + /must contain valid JSON/ + ); + await assert.rejects( + runAiCommand( + ["providers", "put", "openai", "--file", "../outside.json", "--ns", "demo", "--control-url", "http://ctl.test"], + deps + ), + /must stay inside the project/ + ); + assert.equal(calls, 0); + } finally { + rmSync(parent, { recursive: true, force: true }); + } +}); + +test("ai credential put reads a hidden credential and CASes the current provider revision", async () => { + /** @type {ControlCall[]} */ + const calls = []; + /** @type {string[]} */ + const lines = []; + const revision = "0123456789abcdef0123456789abcdef"; + await runAiCommand(["credential", "put", "openai", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin: Readable.from(["secret-key\n"]), + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + return calls.length === 1 + ? response({ provider: { name: "openai", revision, ...PROVIDER } }) + : response({ ok: true, provider: "openai", revision, credentialConfigured: true }); + }, + }); + assert.equal(calls.length, 2); + assert.equal(calls[0].url, "http://ctl.test/ns/demo/ai/providers/openai"); + assert.equal(calls[1].url, "http://ctl.test/ns/demo/ai/providers/openai/credential"); + assert.equal(calls[1].init.method, "PUT"); + assert.deepEqual(JSON.parse(/** @type {string} */ (calls[1].init.body)), { + revision, + credential: "secret-key", + }); + assert.deepEqual(lines, ["OK AI credential configured for openai"]); + assert.equal(lines.join("\n").includes("secret-key"), false); +}); + +test("ai credential put rejects an empty credential before mutation", async () => { + let calls = 0; + await assert.rejects( + runAiCommand(["credential", "put", "openai", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin: Readable.from(["\n"]), + controlFetch: async () => { + calls += 1; + return response({ provider: { name: "openai", revision: "0".repeat(32), ...PROVIDER } }); + }, + }), + /must not be empty/ + ); + assert.equal(calls, 1); +}); + +test("ai providers delete confirms and deletes metadata with its credential", async () => { + const { calls, deps } = mockDeps({ ok: true, deleted: true }); + /** @type {string[]} */ + const lines = []; + await runAiCommand(["providers", "delete", "openai", "--yes", "--ns", "demo", "--control-url", "http://ctl.test"], { + ...deps, + stdout: (/** @type {string} */ line) => lines.push(line), + }); + assert.equal(calls[0].url, "http://ctl.test/ns/demo/ai/providers/openai"); + assert.equal(calls[0].init.method, "DELETE"); + assert.deepEqual(lines, ["OK AI provider openai and its credential deleted"]); +}); + +test("ai rejects incomplete and unknown commands", async () => { + await assert.rejects( + runAiCommand(["providers", "put", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + }), + /requires / + ); + await assert.rejects( + runAiCommand(["unknown", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + }), + /unknown ai command/ + ); +}); diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index f00fbc7..beaaf4a 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -166,6 +166,9 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a 'binding = "BUCKET"', 'bucket_name = "uploads"', "", + "[ai]", + 'binding = "AI"', + "", "[[durable_objects.bindings]]", 'name = "ROOMS"', 'class_name = "Room"', @@ -265,6 +268,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a assert.deepEqual(manifest.bindings, { DB: { type: "d1", databaseId: "cf-id" }, BUCKET: { type: "r2", bucketName: "uploads" }, + AI: { type: "ai" }, ROOMS: { type: "do", className: "Room" }, AUTH: { type: "service", service: "auth-worker", ns: "shared" }, }); @@ -335,13 +339,14 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w main: "src/index.js", vars: { GREETING: "hi" }, exports: [{ entrypoint: "default", allowed_callers: ["*"] }], + ai: { binding: "AI" }, }) ); writeFileSync(path.join(dir, "wrangler.toml"), 'name = "old"\nmain = "old.js"\n'); let tmpConfigSeen = null; let tmpConfigContentAtExec = - /** @type {{ name?: string, main?: string, vars?: unknown, exports?: unknown } | null} */ (null); + /** @type {{ name?: string, main?: string, vars?: unknown, exports?: unknown, ai?: unknown } | null} */ (null); const { calls: fetchCalls, controlFetch } = deployPromoteFetch( { version: "v1", warnings: [] }, { platformDomain: "workers.example" } @@ -377,6 +382,7 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w assert.equal(tmpConfigContentAtExec.main, "src/index.js"); assert.deepEqual(tmpConfigContentAtExec.vars, { GREETING: "hi" }); assert.equal(tmpConfigContentAtExec.exports, undefined); + assert.equal(tmpConfigContentAtExec.ai, undefined); assert.ok(tmpConfigSeen); assert.match(path.basename(tmpConfigSeen), /^\.wrangler\.wdl-tmp-[a-f0-9-]+\.json$/); assert.notEqual(tmpConfigSeen, path.join(dir, ".wrangler.wdl-tmp.json")); diff --git a/tests/unit/cli-wrangler-bindings.test.js b/tests/unit/cli-wrangler-bindings.test.js index 97b1c4c..e624ecb 100644 --- a/tests/unit/cli-wrangler-bindings.test.js +++ b/tests/unit/cli-wrangler-bindings.test.js @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { + parseAiBindingFromCfg, parseD1DatabasesFromCfg, parseDurableObjectsFromCfg, parseExportsFromCfg, @@ -14,6 +15,17 @@ import { } from "../../lib/wrangler/bindings.js"; import { ESC, assertThrowsNoRawTerminalControls } from "./helpers.js"; +test("parseAiBindingFromCfg accepts the singleton binding and rejects unsupported fields", () => { + assert.equal(parseAiBindingFromCfg({}), null); + assert.deepEqual(parseAiBindingFromCfg({ ai: { binding: "AI" } }), { binding: "AI" }); + assert.deepEqual(parseAiBindingFromCfg({ ai: { binding: " AI " } }), { binding: "AI" }); + assert.throws(() => parseAiBindingFromCfg({ ai: [] }), /\[ai\] must be a table/); + assert.throws(() => parseAiBindingFromCfg({ ai: {} }), /\[ai\]\.binding is required/); + assert.throws(() => parseAiBindingFromCfg({ ai: { binding: "AI", remote: true } }), /unsupported field.*remote/); + assert.throws(() => parseAiBindingFromCfg({ ai: { binding: "__WDL_AI__" } }), /runtime-internal/); + assert.throws(() => parseAiBindingFromCfg({ ai: { binding: " __WDL_AI__ " } }), /runtime-internal/); +}); + test("parseTriggers: missing/empty yields []", () => { assert.deepEqual(parseTriggers(undefined), []); assert.deepEqual(parseTriggers(null), []); diff --git a/tests/unit/cli-wrangler-config.test.js b/tests/unit/cli-wrangler-config.test.js index 709b8e0..fd8c09e 100644 --- a/tests/unit/cli-wrangler-config.test.js +++ b/tests/unit/cli-wrangler-config.test.js @@ -280,6 +280,7 @@ test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritab main: "src/index.js", vars: { TOP: "1" }, kv_namespaces: [{ binding: "KV", id: "top" }], + ai: { binding: "AI" }, services: [{ binding: "AUTH", service: "auth" }], queues: { producers: [{ binding: "Q", queue: "top-q" }] }, assets: { directory: "./top-public" }, @@ -289,6 +290,7 @@ test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritab prod: { vars: { ENV: "prod" }, kv_namespaces: [{ binding: "KV", id: "prod" }], + ai: { binding: "PROD_AI" }, queues: { consumers: [{ queue: "jobs" }] }, }, }, @@ -299,6 +301,7 @@ test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritab assert.deepEqual(cfg.vars, { ENV: "prod" }); assert.deepEqual(cfg.kv_namespaces, [{ binding: "KV", id: "prod" }]); + assert.deepEqual(cfg.ai, { binding: "PROD_AI" }); assert.deepEqual(cfg.queues, { consumers: [{ queue: "jobs" }] }); assert.equal(cfg.services, undefined); assert.deepEqual(cfg.assets, { directory: "./top-public" }); @@ -752,7 +755,6 @@ test("validateUnsupportedWranglerConfig: empty env-scoped allowed_callers is sti test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy keys", () => { const objectShapeKeys = new Set([ - "ai", "browser", "cache", "limits", @@ -767,7 +769,6 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy for (const key of [ "addresses", "agent_memory", - "ai", "artifacts", "browser", "cache", From 82c3b337d25bda8939338ddae3ad8590b83a6378 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Sat, 15 Aug 2026 12:55:32 +0800 Subject: [PATCH 2/5] Align AI provider behavior and guidance Report whether provider updates preserve credentials. Document AI lifecycle, environment, WebSocket, and deletion semantics. Add CLI integration coverage and harden the packaged agent demo. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 14 ++- GUIDE-zh.md | 8 +- GUIDE.md | 32 ++++-- commands/ai.js | 9 +- docs/ai-zh.md | 30 +++++- docs/ai.md | 65 ++++++++++-- docs/deploy-zh.md | 4 +- docs/deploy.md | 27 +++-- examples/ai-agent-demo/src/index.js | 36 +++++-- examples/ai-agent-demo/wrangler.toml | 2 +- lib/wrangler/config.js | 2 +- tests/integration/cli-live.test.js | 137 ++++++++++++++++++++++++- tests/unit/cli-ai.test.js | 34 +++++- tests/unit/cli-wrangler-config.test.js | 15 +++ 14 files changed, 354 insertions(+), 61 deletions(-) diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 59f9cc1..9a933dd 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -67,10 +67,12 @@ default platform-domain URL; it requires at least one `route` / `routes` pattern and is not inferred. The deploy summary prints every active route-pattern URL hint, preserving the trailing `*` on prefix patterns, and includes the platform-domain URL only while it is enabled. Cloudflare's separate -`preview_urls` field is unsupported and rejected by the CLI. WDL-only +`preview_urls` field is unsupported and rejected by the CLI. WDL consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, -`[[services]].ns`, `[ai]`, and `[wdl]` are parsed by the CLI and removed from -Wrangler's temporary bundle config; other fields retain their existing Wrangler +`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes them from Wrangler's +temporary bundle config. `[ai]` is standard Wrangler configuration; WDL accepts +only its `binding` field and maps it into the WDL manifest. The other listed +shapes are WDL extensions. Other fields retain their existing Wrangler passthrough behavior. Specific nested fields that WDL cannot represent are rejected rather than silently dropped, including Cloudflare Artifacts `triggers.events` subscriptions and R2 `local_dev.experimental_s3_credentials`. @@ -103,6 +105,12 @@ trusted projects. For a less-trusted or third-party project, recommend `--no-token-store` (or `WDL_TOKEN_STORE=off`) with an ephemeral `--token` / `--control-url`, rather than relying on the global store. +Treat `wdl ai providers delete` as destructive: it removes both provider +metadata and its credential and has no dry-run. Run `wdl config explain` first +to confirm the resolved namespace, inspect the target with +`wdl ai providers get --ns `, and use the same explicit +`--ns` for deletion. Never add `--yes` without user confirmation. + `templates/AGENTS.md` is the generic agent entrypoint that `wdl init` copies into every new project. It points at the same `docs/` through `node_modules/@wdl-dev/cli/docs/.md` paths. diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 75a6f89..9cc97af 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -264,7 +264,7 @@ Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canon | Analytics Engine | 暂不支持,部署时会拒绝 | | 其他未映射的 Wrangler 绑定/配置/策略段(例如 `vectorize`、`hyperdrive`、`agent_memory`、`websearch`、`media`、`stream`、`ratelimits`、`vpc_services`、`cloudchamber`、`containers`、`wasm_modules`、`[site]`、`limits`、`placement`、`observability`、`pages_build_output_dir`) | 不支持;部署时显式报错,不会静默丢弃绑定/配置。CLI 报错会点名被拒字段;内部拒绝列表跟随打包的 Wrangler schema,这里不复刻完整清单 | -WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns`、`[ai]` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 +WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns`、`[ai]` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除它们。`[ai]` 是 Wrangler 标准配置;WDL 只接受其中的 `binding` 字段,并把该声明映射到 WDL manifest。面向租户的数组形态 `[[exports]]` 和其余这些字段是 WDL 扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 Cron triggers 和 queue consumers 是运行时 dispatch 能力。除非管理方明确给了 reserved namespace,否则只应声明在 tenant namespace 里的可路由 Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是公开/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 @@ -377,7 +377,9 @@ printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai wdl ai models ``` -Provider 元数据选择官方 adapter(`openai`、`xai` 或 `deepseek`),并把 `openai/primary` 这类有界 alias 映射到原生 model id 和 capability。更新元数据会生成新 revision 并清除旧 credential;推理前必须重新配置凭据。凭据是加密的 namespace 资源,不会进入 Worker env 或 bundle metadata。 +Provider 元数据选择官方 adapter(`openai`、`xai` 或 `deepseek`),并把 `openai/primary` 这类有界 alias 映射到原生 model id 和 capability。更新元数据会生成新 revision;同 kind 更新保留既有 credential,切换 adapter kind 才会清除 credential 并要求在推理前重新配置。凭据是加密的 namespace 资源,不会进入 Worker env 或 bundle metadata。 + +`wdl ai providers delete ` 默认会提示确认,并同时删除 provider metadata 和 credential。该命令没有 dry-run。先运行 `wdl config explain` 确认最终解析出的 namespace,再用 `wdl ai providers get --ns ` 查看目标,并在删除时传入同一个显式 `--ns`。只有完成这项独立检查并与用户确认后,才能传 `--yes`。 Agent 代码使用 provider 原生 Responses 形态: @@ -389,7 +391,7 @@ const response = await env.AI.run("openai/primary", { }); ``` -在模型声明相应 transport 时,`run()` 还支持语义 SSE(`stream: true`)、`AbortSignal`、Chat Completions、Embeddings 和显式 WebSocket mode。`fetch()` 是原始 OpenAI-compatible transport,官方 OpenAI JavaScript SDK 可通过它完成 JSON、SSE 和取消。`models()` 返回当前 namespace 中已配置凭据的模型描述。 +在模型声明相应 transport 时,`run()` 还支持语义 SSE(`stream: true`)、`AbortSignal`、Chat Completions、Embeddings 和显式 WebSocket mode。`fetch()` 是原始 OpenAI-compatible transport,官方 OpenAI JavaScript SDK 可通过它完成 JSON、SSE 和取消。`wdl ai models` 返回 namespace 当前的全部模型元数据,不按 credential 状态过滤;Worker 代码中的 `models()` 与 `run()` 在已加载 module 生命周期内共享一份 catalog snapshot,descriptor 变更要等重载或重新部署后生效,credential 和 upstream model 变化则在下一次推理调用中生效。 WDL 不执行 function tool、不自动重连模型 WebSocket、不接受任意 provider endpoint,也不向租户暴露 provider 凭据。Provider JSON、SDK 配置、WebSocket 形态、边界及 `examples/ai-agent-demo` 的完整 tool loop 见 [`docs/ai-zh.md`](./docs/ai-zh.md)。 diff --git a/GUIDE.md b/GUIDE.md index 744d632..7426723 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -393,11 +393,14 @@ with `worker_env_too_large`. | Analytics Engine | Not currently supported; deploy fails if configured | | Other unmapped Wrangler binding/config/policy sections (for example `vectorize`, `hyperdrive`, `agent_memory`, `websearch`, `media`, `stream`, `ratelimits`, `vpc_services`, `cloudchamber`, `containers`, `wasm_modules`, `[site]`, `limits`, `placement`, `observability`, `pages_build_output_dir`) | Not supported; deploy fails loudly instead of silently dropping the binding/config. The CLI error names the rejected field; the internal rejection list tracks the bundled Wrangler schema and is not reproduced exhaustively here | -WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, -`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes these private -extensions from the temporary config passed to the Wrangler bundler. Other -fields retain their existing Wrangler passthrough behavior. Wrangler's -object-shaped declarative `exports` configuration is not supported by WDL. +WDL consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, +`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes them from the +temporary config passed to the Wrangler bundler. `[ai]` is standard Wrangler +configuration; WDL accepts only its `binding` field and maps that declaration +into the WDL manifest. The tenant-facing array-shaped `[[exports]]` and the +other listed fields are WDL extensions. Other fields retain their existing +Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` +configuration is not supported by WDL. Cron triggers and queue consumers are dispatch features. Declare them only on routeable Workers in tenant namespaces unless your operator gives you an @@ -566,9 +569,17 @@ wdl ai models Provider metadata selects one official adapter (`openai`, `xai`, or `deepseek`) and maps bounded aliases such as `openai/primary` to native model ids and -capabilities. Updating metadata creates a new revision and clears the old -credential; configure it again before inference. Credentials are encrypted -namespace resources and never enter Worker env or bundle metadata. +capabilities. Updating metadata creates a new revision. Same-kind updates +preserve an existing credential; changing the adapter kind clears it and +requires `credential put` before inference. Credentials are encrypted namespace +resources and never enter Worker env or bundle metadata. + +`wdl ai providers delete ` prompts by default and deletes both the +provider metadata and its credential. It has no dry-run. First run +`wdl config explain` to confirm the resolved namespace, then inspect the target +with `wdl ai providers get --ns ` and use the same +explicit `--ns` for deletion. Pass `--yes` only after that independent check and +user confirmation. Agent code uses the provider-native Responses shape: @@ -584,7 +595,10 @@ const response = await env.AI.run("openai/primary", { Completions, Embeddings, and explicit WebSocket mode when the model advertises the matching transport. `fetch()` is the raw OpenAI-compatible transport and works with the official OpenAI JavaScript SDK for JSON, SSE, and cancellation. -`models()` returns the current namespace's credential-backed model descriptors. +`wdl ai models` returns the current namespace metadata regardless of credential +status. In Worker code, `models()` and `run()` share a catalog snapshot for the +loaded module lifecycle; descriptor edits require a reload or redeploy, while +credential and upstream-model changes apply to the next inference call. WDL does not execute function tools, auto-reconnect model WebSockets, accept arbitrary provider endpoints, or expose provider credentials. See diff --git a/commands/ai.js b/commands/ai.js index 03c7a69..8c191c2 100644 --- a/commands/ai.js +++ b/commands/ai.js @@ -131,10 +131,11 @@ async function runAi({ values, positionals, context }) { ) ); if (writeJsonOr(values.json === true, body, stdout)) return; - writeStatusLine( - stdout, - `OK AI provider ${escapeTerminalText(provider)} saved; credential cleared, configure it before use` - ); + const credentialStatus = + body.provider?.credentialConfigured === true + ? "existing credential preserved" + : "credential not configured; configure it before use"; + writeStatusLine(stdout, `OK AI provider ${escapeTerminalText(provider)} saved; ${credentialStatus}`); return; } diff --git a/docs/ai-zh.md b/docs/ai-zh.md index d45adc7..0c03991 100644 --- a/docs/ai-zh.md +++ b/docs/ai-zh.md @@ -19,6 +19,8 @@ binding = "AI" 该表只接受 `binding`。provider 由每次调用的模型 id 选择,而不是写在 Wrangler 配置中。与其它资源 binding 一样,AI binding 按 environment 隔离。 +最不易误用的方式是使用 handler 或 Durable Object 的位置参数 `env`。代码可以从 `cloudflare:workers` 导入 `{ env }`,并在 invocation 内读取 `env.AI`;但不能在模块求值期间缓存 `env.AI` 后期待它具有 `run()` 或 `models()`,那个过早取得的值只有原始 `fetch()` host binding。启用 `disallow_importable_env` 时,只有位置参数 env 提供完整 facade。 + ## 配置 provider Provider 元数据和凭据是命名空间资源。即使命名空间暂时没有任何已部署 Worker,它们仍会保留,与 namespace secret 的生命周期一致。 @@ -57,7 +59,7 @@ wdl ai providers get openai --ns wdl ai models --ns ``` -`providers put` 会生成新的 provider revision 并清除旧凭据,防止变更后的目标或模型定义静默继承为旧 revision 批准的凭据。每次更新元数据后都要重新执行 `credential put`。凭据从隐藏 TTY 输入或 stdin 读取;CLI 不提供命令行 credential flag,list/get 也绝不返回凭据值。官方 provider credential 必须是不含空白的 visible-ASCII bearer token。 +`providers put` 会生成新的 provider revision。同一官方 adapter kind 内更新时保留既有 credential;切换 kind 时清除 credential,新建 provider 则默认没有 credential。返回的 provider 状态显示 credential 缺失时,才需要执行 `credential put`。凭据从隐藏 TTY 输入或 stdin 读取;CLI 不提供命令行 credential flag,list/get 也绝不返回凭据值。官方 provider credential 必须是不含空白的 visible-ASCII bearer token。 支持的 provider kind 与规范上游范围: @@ -80,7 +82,13 @@ wdl ai providers delete [--yes] [--json] wdl ai models [--json] ``` -删除 provider 会同时删除其凭据。CLI 有意不复制完整 descriptor grammar 和总量限制;Control 是唯一权威校验者。 +Model list 包含全部已配置的 provider metadata,不按 credential 状态过滤;所选 provider 缺少 credential 时,推理仍会 fail closed。 + +`wdl ai models` 读取 Control 的当前权威状态。一个已加载 Worker 内,`env.AI.models()` 与 `run()` 在该 module 生命周期中共享一份延迟加载的 catalog snapshot;alias、protocol、transport、modality 和 capability 变更要等 module 重载或重新部署后才可见。Credential 变化和 upstream model 轮换会在每次推理时重新解析,无需 redeploy;补上缺失 credential 后,下一次调用也会立即生效。 + +`providers delete` 默认会提示确认,并同时删除 provider metadata 和 credential。该命令没有 dry-run。先运行 `wdl config explain` 确认最终解析出的 namespace,再用 `wdl ai providers get --ns ` 查看目标,并在删除时传入同一个显式 `--ns`。只有完成这项独立检查并与用户确认后,才能传 `--yes`。 + +CLI 有意不复制完整 descriptor grammar 和总量限制;Control 是唯一权威校验者。 ## 使用 `run()` 构建 Agent @@ -165,6 +173,22 @@ socket.send(JSON.stringify({ type: "session.update", session: {} })); 应用负责 provider 协议帧、重连和 close 处理。WDL 桥接文本/二进制帧及 provider close code,但不会恢复已中断的模型会话。在 Durable Object 中保持长连接会消耗 do-runtime 自己的 AI pool,并可能让 actor 保持活跃。 +如果应用把 AI socket 桥接到另一个公开 `WebSocketPair`,必须把 AI upgrade headers 复制到自己的公开 `101` response。它们会让 Gateway 在 runtime 丢失时终止 session,而不是静默替换 runtime 并创建新的 provider session: + +```js +const aiUpgrade = await env.AI.run("openai/realtime", null, { + websocket: true, +}); +// Bridge aiUpgrade.webSocket to client. +return new Response(null, { + status: 101, + webSocket: client, + headers: aiUpgrade.headers, +}); +``` + +Gateway 会在发送公开 response 前消费掉内部 policy header。 + ## 安全与运维边界 - Provider 凭据加密落盘,绝不进入 bundle metadata、生成源码、租户原始 env、日志或请求参数。 @@ -187,5 +211,5 @@ printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai --ns wdl deploy . --ns curl -X POST -H 'content-type: application/json' \ -d '{"prompt":"What time is it in Asia/Tokyo?"}' \ - https://-ai-agent-demo./ + https://./ai-agent-demo/ ``` diff --git a/docs/ai.md b/docs/ai.md index b277727..10ad7e2 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -28,6 +28,13 @@ The table accepts only `binding`. Provider selection is made by the model id on each call, not in Wrangler config. The binding is environment-scoped like other resource bindings. +Use the positional handler or Durable Object `env` for the least surprising +surface. Code may import `{ env }` from `cloudflare:workers` and read `env.AI` +during an invocation, but it must not cache `env.AI` during module evaluation +and expect `run()` or `models()` there: that early value is the raw +`fetch()`-only host binding. With `disallow_importable_env`, only positional env +provides the facade. + ## Configure a provider Provider metadata and credentials are namespace resources. They remain after the @@ -67,13 +74,13 @@ wdl ai providers get openai --ns wdl ai models --ns ``` -`providers put` creates a new provider revision and clears any prior credential. -This prevents a changed destination/model definition from silently inheriting a -credential approved for an older revision. Run `credential put` again after -every metadata update. Credential input is read from hidden TTY input or stdin; -there is no command-line credential flag and credential values are never -returned by list/get commands. Official provider credentials must be -visible-ASCII bearer tokens without whitespace. +`providers put` creates a new provider revision. An update that keeps the same +official adapter kind preserves an existing credential; changing kind clears it, +and a newly created provider has none. Run `credential put` whenever the +returned provider state reports a missing credential. Credential input is read +from hidden TTY input or stdin; there is no command-line credential flag and +credential values are never returned by list/get commands. Official provider +credentials must be visible-ASCII bearer tokens without whitespace. Supported provider kinds and canonical upstream ownership: @@ -98,9 +105,26 @@ wdl ai providers delete [--yes] [--json] wdl ai models [--json] ``` -Deleting a provider also deletes its credential. The CLI intentionally leaves -the complete descriptor grammar and aggregate limits to Control, which is the -canonical validator. +The model list contains configured provider metadata regardless of credential +status. Inference still fails closed until the selected provider has a +credential. + +`wdl ai models` reads the current Control state. Inside a loaded Worker, +`env.AI.models()` and `run()` share one lazily loaded catalog snapshot for that +module lifecycle. Alias, protocol, transport, modality, and capability edits +therefore become visible after a reload or redeploy. Credential changes and +upstream-model rotation are resolved for every inference call and take effect +without redeploy; adding a missing credential likewise enables the next call. + +`providers delete` prompts by default and deletes both the provider metadata and +its credential. It has no dry-run. First run `wdl config explain` to confirm the +resolved namespace, then inspect the target with +`wdl ai providers get --ns ` and use the same explicit +`--ns` for deletion. Pass `--yes` only after that independent check and user +confirmation. + +The CLI intentionally leaves the complete descriptor grammar and aggregate +limits to Control, which is the canonical validator. ## Agent calls with `run()` @@ -200,6 +224,25 @@ WDL bridges text/binary frames and provider close codes but does not resume an interrupted model session. Long-lived AI sockets in a Durable Object consume the do-runtime AI pool and can keep that actor active. +If an application bridges the AI socket to a separate public `WebSocketPair`, +copy the AI upgrade headers onto that public `101` response. They tell Gateway +to terminate the session instead of silently replacing a lost runtime and +creating a fresh provider session: + +```js +const aiUpgrade = await env.AI.run("openai/realtime", null, { + websocket: true, +}); +// Bridge aiUpgrade.webSocket to client. +return new Response(null, { + status: 101, + webSocket: client, + headers: aiUpgrade.headers, +}); +``` + +Gateway consumes the internal policy header before sending the public response. + ## Security and operational boundaries - Provider credentials are encrypted at rest and never enter bundle metadata, @@ -231,5 +274,5 @@ printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai --ns wdl deploy . --ns curl -X POST -H 'content-type: application/json' \ -d '{"prompt":"What time is it in Asia/Tokyo?"}' \ - https://-ai-agent-demo./ + https://./ai-agent-demo/ ``` diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index ee47758..c0cb5e5 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -125,7 +125,7 @@ wdl deploy . --env production **支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[ai]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`route` / `routes`、`workers_dev`、`[wdl] session_policy`、`[env.]`。 -WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns`、`[ai]` 和 `[wdl]` 本身,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。`[wdl] session_policy` 见上面的会话策略一节。 +WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns`、`[ai]` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除它们。`[ai]` 是 Wrangler 标准配置;WDL 只接受其中的 `binding` 字段,并把该声明映射到 WDL manifest。面向租户的数组形态 `[[exports]]` 和其余这些字段是 WDL 扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。`[wdl] session_policy` 见上面的会话策略一节。 WDL 还会拒绝 Cloudflare Artifacts `triggers.events` subscription 和 R2 `local_dev.experimental_s3_credentials`;这两个字段都没有对应的 WDL deploy manifest 或 runtime 映射。 @@ -139,7 +139,7 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 ## 破坏性命令 -`wdl delete worker`、`wdl delete version`、`wdl d1 delete`、`wdl secret delete` 默认会提示确认。如果有 `--dry-run`,先跑一遍(或先做只读检查),然后跟用户确认了再加 `--yes`。**不要**主动加 `--yes`。 +`wdl delete worker`、`wdl delete version`、`wdl d1 delete`、`wdl secret delete` 和 `wdl ai providers delete` 默认会提示确认。如果有 `--dry-run`,先跑一遍;否则先做只读检查。删除 AI provider 前,先运行 `wdl config explain` 确认最终解析出的 namespace,再用 `wdl ai providers get --ns ` 查看目标,并在删除时传入同一个显式 `--ns`;删除 provider 会同时删除其 metadata 和 credential。只有与用户确认后才能加 `--yes`;**不要**主动加。 `wdl workers` 会显示 `workflow-defs=yes` 或 `workflow-defs=no`;`unknown` 表示旧 control 没有返回该字段,不表示没有 workflow definitions。即使 blocker 使 `wouldDelete=no`,worker delete dry-run 仍会报告 secret 和 workflow-definition 是否存在。 diff --git a/docs/deploy.md b/docs/deploy.md index efe061c..2afc579 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -239,12 +239,15 @@ timezone, a platform extension), `[[queues.producers]]` / `[[queues.consumers]]`, `[[services]]`, `[[platform_bindings]]`, `[[exports]]`, `route` / `routes`, `workers_dev`, `[wdl] session_policy`, `[env.]`. -WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, -`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes these private -extensions from the temporary config passed to the Wrangler bundler. Other -fields retain their existing Wrangler passthrough behavior. Wrangler's -object-shaped declarative `exports` configuration is not supported by WDL. -`[wdl] session_policy` has its own section above. +WDL consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, +`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes them from the +temporary config passed to the Wrangler bundler. `[ai]` is standard Wrangler +configuration; WDL accepts only its `binding` field and maps that declaration +into the WDL manifest. The tenant-facing array-shaped `[[exports]]` and the +other listed fields are WDL extensions. Other fields retain their existing +Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` +configuration is not supported by WDL. `[wdl] session_policy` has its own +section above. WDL also rejects Cloudflare Artifacts `triggers.events` subscriptions and R2 `local_dev.experimental_s3_credentials`: neither field has a WDL deploy-manifest @@ -281,10 +284,14 @@ consumers. ## Destructive commands -`wdl delete worker`, `wdl delete version`, `wdl d1 delete`, and -`wdl secret delete` prompt for confirmation by default. If `--dry-run` exists, -run it first (or do a read-only check), then add `--yes` only after confirming -with the user. Do **not** add `--yes` on your own. +`wdl delete worker`, `wdl delete version`, `wdl d1 delete`, `wdl secret delete`, +and `wdl ai providers delete` prompt for confirmation by default. If `--dry-run` +exists, run it first; otherwise do a read-only check. Before deleting an AI +provider, run `wdl config explain` to confirm the resolved namespace, inspect +the target with `wdl ai providers get --ns `, and use the +same explicit `--ns` for deletion. Provider deletion removes both its metadata +and credential. Add `--yes` only after confirming with the user; do **not** add +it on your own. `wdl workers` reports `workflow-defs=yes` or `workflow-defs=no`; `unknown` means an older control omitted the field, not that no definitions exist. Worker delete diff --git a/examples/ai-agent-demo/src/index.js b/examples/ai-agent-demo/src/index.js index b89d494..5931dc1 100644 --- a/examples/ai-agent-demo/src/index.js +++ b/examples/ai-agent-demo/src/index.js @@ -13,6 +13,8 @@ const TIME_TOOL = { strict: true, }; +const MAX_TOOL_ROUNDS = 8; + function json(value, init = {}) { const headers = new Headers(init.headers); headers.set("content-type", "application/json; charset=utf-8"); @@ -39,19 +41,22 @@ function executeTool(call) { } async function runAgent(ai, prompt) { - const first = await ai.run("openai/primary", { + let response = await ai.run("openai/primary", { input: prompt, tools: [TIME_TOOL], tool_choice: "auto", reasoning: { effort: "low" }, }); - const calls = (first.output || []).filter((item) => item.type === "function_call"); - if (calls.length === 0) return first; - return await ai.run("openai/primary", { - previous_response_id: first.id, - input: calls.map(executeTool), - tools: [TIME_TOOL], - }); + for (let round = 0; ; round += 1) { + const calls = (response.output || []).filter((item) => item.type === "function_call"); + if (calls.length === 0) return response; + if (round >= MAX_TOOL_ROUNDS) throw new Error(`tool loop exceeded ${MAX_TOOL_ROUNDS} rounds`); + response = await ai.run("openai/primary", { + previous_response_id: response.id, + input: calls.map(executeTool), + tools: [TIME_TOOL], + }); + } } export default { @@ -64,8 +69,19 @@ export default { }); } if (request.method !== "POST") return json({ error: "method_not_allowed" }, { status: 405 }); - const body = await request.json(); - const prompt = typeof body.prompt === "string" ? body.prompt : "What time is it in Asia/Tokyo?"; + let body; + try { + body = await request.json(); + } catch { + return json({ error: "invalid_request", message: "request body must be valid JSON" }, { status: 400 }); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return json({ error: "invalid_request", message: "request body must be a JSON object" }, { status: 400 }); + } + const prompt = body.prompt; + if (typeof prompt !== "string") { + return json({ error: "invalid_request", message: "prompt must be a string" }, { status: 400 }); + } try { const response = await runAgent(env.AI, prompt); return json({ id: response.id, text: responseText(response), response }); diff --git a/examples/ai-agent-demo/wrangler.toml b/examples/ai-agent-demo/wrangler.toml index 176b3b1..3edede1 100644 --- a/examples/ai-agent-demo/wrangler.toml +++ b/examples/ai-agent-demo/wrangler.toml @@ -1,6 +1,6 @@ name = "ai-agent-demo" main = "src/index.js" -compatibility_date = "2026-08-11" +compatibility_date = "2026-06-17" [ai] binding = "AI" diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 5ea3d4b..7027c9c 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -166,7 +166,7 @@ export function formatWranglerConfigShadowWarning(loaded) { /** * Build the config passed to Wrangler's dry-run bundler. WDL consumes these - * private extensions itself, so Wrangler must not interpret or validate them. + * fields itself, so Wrangler must not interpret or validate them. * The source config stays untouched because WDL still needs the full shape for * its deploy manifest. * @param {unknown} rawCfg diff --git a/tests/integration/cli-live.test.js b/tests/integration/cli-live.test.js index 2ffdfbb..b77f189 100644 --- a/tests/integration/cli-live.test.js +++ b/tests/integration/cli-live.test.js @@ -111,6 +111,17 @@ import { controlFetch } from "../../lib/control-fetch.js"; * @typedef {{ status: string }} WorkflowStatusResult */ +/** + * @typedef {{ + * name: string, + * credentialConfigured: boolean, + * models: Record, + * }} AiProvider + * @typedef {{ provider: AiProvider }} AiProviderResult + * @typedef {{ providers: AiProvider[] }} AiProvidersResult + * @typedef {{ models: Array<{ id: string }> }} AiModelsResult + */ + /** * @typedef {{ worker?: string }} TenantHealthBody * @typedef {{ name?: string }} TenantD1Body @@ -126,6 +137,9 @@ const DEFAULT_LOCAL_ADMIN_TOKEN = "local-dev-token"; const DEFAULT_LOCAL_PLATFORM_DOMAIN = "workers.local"; const DEFAULT_LOCAL_GATEWAY_ORIGIN = `http://localhost:${LOCAL_GATEWAY_PORT}`; const LIVE_WORKER_COMPATIBILITY_DATE = process.env.WDL_LIVE_COMPATIBILITY_DATE || "2026-06-17"; +// WDL's integration suite owns inference; this CLI fixture never calls an upstream AI provider. +const LIVE_AI_CREDENTIAL = "cli-live-nonfunctional-key"; +const LIVE_AI_UPSTREAM_MODEL = "cli-live-model"; const LIVE_TIMEOUT_MS = 20 * 60_000; const TENANT_REQUEST_TIMEOUT_MS = 30_000; @@ -144,10 +158,13 @@ test( let wfDir = ""; let envDir = ""; let initDir = ""; + let aiDir = ""; /** @type {NodeJS.ProcessEnv | null} */ let storeEnv = null; const cleaned = { appWorker: false, + aiProvider: false, + aiWorker: false, d1: false, }; @@ -186,6 +203,7 @@ test( const doWorker = "cli-live-do"; const wfWorker = "cli-live-wf"; const envWorker = "cli-live-env"; + const aiWorker = "cli-live-ai"; const dbName = `${ns}-main`; const bucket = `cli-live-${ns}`; const kvId = `${ns}-kv`; @@ -233,6 +251,7 @@ test( "delete", "d1", "r2", + "ai", "tail", "workflows", "token", @@ -276,11 +295,12 @@ test( assert.ok(Array.isArray(doctor.checks)); }); - await step("write live app and workflow fixtures", () => { + await step("write live project fixtures", () => { appDir = writeAppProject(tempRoot, { worker: appWorker, dbName, bucket, kvId, queueName }); doDir = writeDurableObjectProject(tempRoot, { worker: doWorker }); wfDir = writeWorkflowProject(tempRoot, { worker: wfWorker }); envDir = writeEnvProject(tempRoot, { worker: envWorker }); + aiDir = writeAiProject(tempRoot, { worker: aiWorker }); }); cleanupStep("delete d1 database", () => { @@ -307,6 +327,14 @@ test( cleanupStep("delete env worker", () => { run(["delete", "worker", envWorker, "--yes", "--json"], { env: directTenantEnv }); }); + cleanupStep("delete AI provider", () => { + if (!cleaned.aiProvider) { + run(["ai", "providers", "delete", "openai", "--yes", "--json"], { env: directTenantEnv }); + } + }); + cleanupStep("delete AI worker", () => { + if (!cleaned.aiWorker) run(["delete", "worker", aiWorker, "--yes", "--json"], { env: directTenantEnv }); + }); await step("d1 commands create, migrate, list, execute", () => { const createdDb = /** @type {D1CreateResult} */ ( @@ -408,6 +436,47 @@ test( assert.equal(durableObjectHit.storedHits, 1); }); + await step("AI commands configure and deploy a binding while preserving its credential", () => { + const providerFile = path.join(aiDir, "provider.openai.json"); + const created = run(["ai", "providers", "put", "openai", "--file", providerFile, "--json"], { + cwd: aiDir, + env: storeEnv, + }); + const createdProvider = /** @type {AiProviderResult} */ (JSON.parse(created.stdout)).provider; + assert.equal(createdProvider.credentialConfigured, false); + + const modelsBeforeCredential = /** @type {AiModelsResult} */ ( + runJson(["ai", "models", "--json"], { env: storeEnv }) + ); + assert.deepEqual( + modelsBeforeCredential.models.map((model) => model.id), + ["openai/primary"] + ); + + const aiDeploy = run(["deploy", aiDir], { env: storeEnv, timeoutMs: 5 * 60_000 }); + assertDeployPrintedLiveVersion(aiDeploy.stdout); + + run(["ai", "credential", "put", "openai"], { + env: storeEnv, + input: `${LIVE_AI_CREDENTIAL}\n`, + }); + const updated = run(["ai", "providers", "put", "openai", "--file", providerFile], { + cwd: aiDir, + env: storeEnv, + }); + assert.match(updated.stdout, /existing credential preserved/); + + const provider = /** @type {AiProviderResult} */ ( + runJson(["ai", "providers", "get", "openai", "--json"], { env: storeEnv }) + ).provider; + assert.equal(provider.credentialConfigured, true); + assert.equal(provider.models.primary.upstreamModel, LIVE_AI_UPSTREAM_MODEL); + const providers = /** @type {AiProvidersResult} */ ( + runJson(["ai", "providers", "list", "--json"], { env: storeEnv }) + ); + assert.equal(providers.providers[0]?.credentialConfigured, true); + }); + await step("r2 commands list, head, get, delete objects", () => { assert.ok( /** @type {R2BucketsResult} */ (runJson(["r2", "buckets", "list", "--json"], { env: storeEnv })).buckets.some( @@ -522,6 +591,10 @@ test( }); await step("explicit cleanup commands", () => { + runJson(["delete", "worker", aiWorker, "--yes", "--json"], { env: storeEnv }); + cleaned.aiWorker = true; + runJson(["ai", "providers", "delete", "openai", "--yes", "--json"], { env: storeEnv }); + cleaned.aiProvider = true; runJson(["delete", "worker", appWorker, "--yes", "--json"], { env: storeEnv }); cleaned.appWorker = true; runJson(["d1", "delete", dbName, "--yes", "--json"], { env: storeEnv }); @@ -1123,6 +1196,68 @@ export default { return dir; } +/** + * @param {string} root + * @param {{ worker: string }} fixture + * @returns {string} + */ +function writeAiProject(root, { worker }) { + const dir = path.join(root, "ai"); + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync( + path.join(dir, "package.json"), + JSON.stringify( + { + private: true, + type: "module", + }, + null, + 2 + ) + "\n" + ); + writeFileSync( + path.join(dir, "provider.openai.json"), + JSON.stringify( + { + kind: "openai", + models: { + primary: { + upstreamModel: LIVE_AI_UPSTREAM_MODEL, + protocol: "responses", + transports: ["http"], + inputModalities: ["text"], + outputModalities: ["text"], + }, + }, + }, + null, + 2 + ) + "\n" + ); + writeFileSync( + path.join(dir, "wrangler.toml"), + ` +name = "${worker}" +main = "src/index.js" +compatibility_date = "${LIVE_WORKER_COMPATIBILITY_DATE}" + +[ai] +binding = "AI" +` + ); + writeFileSync( + path.join(dir, "src", "index.js"), + ` +export default { + fetch() { + return new Response("AI binding fixture"); + }, +}; +` + ); + return dir; +} + /** * @param {string} root * @param {{ worker: string }} fixture diff --git a/tests/unit/cli-ai.test.js b/tests/unit/cli-ai.test.js index cf212d1..601a1af 100644 --- a/tests/unit/cli-ai.test.js +++ b/tests/unit/cli-ai.test.js @@ -100,7 +100,7 @@ test("ai providers get encodes path segments and prints credential state", async ]); }); -test("ai providers put reads project-local JSON and warns that credentials were cleared", async () => { +test("ai providers put reads project-local JSON and reports returned credential state", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-ai-provider-")); try { writeFileSync(path.join(dir, "provider.json"), JSON.stringify(PROVIDER)); @@ -119,14 +119,42 @@ test("ai providers put reads project-local JSON and warns that credentials were /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} ) => { calls.push({ url, init }); - return response({ provider: { name: "openai", revision: "0".repeat(32), ...PROVIDER } }); + return response({ + provider: { + name: "openai", + revision: "0".repeat(32), + credentialConfigured: true, + ...PROVIDER, + }, + }); }, } ); assert.equal(calls[0].url, "http://ctl.test/ns/demo/ai/providers/openai"); assert.equal(calls[0].init.method, "PUT"); assert.deepEqual(JSON.parse(/** @type {string} */ (calls[0].init.body)), PROVIDER); - assert.deepEqual(lines, ["OK AI provider openai saved; credential cleared, configure it before use"]); + assert.deepEqual(lines, ["OK AI provider openai saved; existing credential preserved"]); + + /** @type {string[]} */ + const missingLines = []; + await runAiCommand( + ["providers", "put", "openai", "--file", "provider.json", "--ns", "demo", "--control-url", "http://ctl.test"], + { + cwd: dir, + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => missingLines.push(line), + controlFetch: async () => + response({ + provider: { + name: "openai", + revision: "1".repeat(32), + credentialConfigured: false, + ...PROVIDER, + }, + }), + } + ); + assert.deepEqual(missingLines, ["OK AI provider openai saved; credential not configured; configure it before use"]); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/unit/cli-wrangler-config.test.js b/tests/unit/cli-wrangler-config.test.js index fd8c09e..e68d154 100644 --- a/tests/unit/cli-wrangler-config.test.js +++ b/tests/unit/cli-wrangler-config.test.js @@ -309,6 +309,21 @@ test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritab assert.equal(cfg.workers_dev, false); }); +test("resolveWranglerConfig: a top-level AI binding does not inherit into a selected environment", () => { + const { cfg } = resolveWranglerConfig( + { + name: "demo", + main: "src/index.js", + ai: { binding: "AI" }, + env: { prod: {} }, + }, + "prod", + "wrangler.toml" + ); + + assert.equal(cfg.ai, undefined); +}); + test("resolveWranglerConfig: selected environment can override inherited assets", () => { const { cfg } = resolveWranglerConfig( { From 5de8034629baa2fbc21d5cd63e40525f639fa0ca Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Sun, 16 Aug 2026 09:58:26 +0800 Subject: [PATCH 3/5] Harden AI credential and demo workflows Redact malformed AI, secret, and token arguments while retaining unambiguous option ordering. Improve AI credential error guidance and surface named-environment binding omissions. Protect the AI agent demo with a Worker bearer token and return tool failures to the model. Document provider replacement, credential handling, file containment, and demo security behavior. Signed-off-by: Lu Zhang --- .claude/rules/examples.md | 14 ++- .claude/skills/wdl-deploy/SKILL.md | 56 ++++++---- CHANGELOG.md | 9 +- GUIDE-zh.md | 15 ++- GUIDE.md | 34 ++++-- commands/ai.js | 98 +++++++++++++---- commands/secret.js | 34 +++--- commands/token.js | 1 + docs/ai-zh.md | 25 ++++- docs/ai.md | 38 ++++++- docs/deploy-zh.md | 2 +- docs/deploy.md | 9 +- docs/env-overrides-zh.md | 2 +- docs/env-overrides.md | 3 +- docs/secrets-zh.md | 2 + docs/secrets.md | 6 ++ docs/token-zh.md | 2 + docs/token.md | 5 + examples/ai-agent-demo/src/index.js | 56 ++++++++-- lib/command.js | 56 +++++++++- lib/common.js | 53 +++++++++ lib/wrangler-pack.js | 5 + lib/wrangler/config.js | 31 +++++- templates/AGENTS.md | 2 + tests/unit/cli-ai.test.js | 142 ++++++++++++++++++++++++- tests/unit/cli-command.test.js | 55 ++++++++++ tests/unit/cli-common.test.js | 27 ++++- tests/unit/cli-deploy.test.js | 31 +++++- tests/unit/cli-secret.test.js | 122 ++++++++++++++++----- tests/unit/cli-token.test.js | 37 +++++-- tests/unit/cli-wrangler-config.test.js | 6 +- 31 files changed, 828 insertions(+), 150 deletions(-) diff --git a/.claude/rules/examples.md b/.claude/rules/examples.md index a6ef96f..f889dd7 100644 --- a/.claude/rules/examples.md +++ b/.claude/rules/examples.md @@ -79,7 +79,16 @@ To pick: for a worker that serves a page or fronts an external API, start from wdl deploy . --ns ``` For `env-overrides-demo`, use `wdl deploy . --env preview --ns ` or - `wdl deploy . --env production --ns `. + `wdl deploy . --env production --ns `. For `ai-agent-demo`, configure the + namespace provider credential and a Worker-level demo access token before + deploy: + ```bash + wdl ai providers put openai --file provider.openai.json --ns + printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai --ns + AI_DEMO_TOKEN="$(openssl rand -hex 32)" + printf '%s' "$AI_DEMO_TOKEN" | wdl secret put --worker AI_DEMO_TOKEN --ns + wdl deploy . --ns + ``` ## Anti-patterns @@ -94,6 +103,9 @@ To pick: for a worker that serves a page or fronts an external API, start from worker actually calls. - ❌ Leaving `"name": ""` in `package.json` or wrangler config. Two workers with the same name collide on deploy. +- ❌ Removing the `ai-agent-demo` bearer gate or deploying a derived public AI + endpoint without application authentication. WDL does not provide a spend + quota for provider calls. ## Deploy diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 9a933dd..b898732 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -69,27 +69,29 @@ hint, preserving the trailing `*` on prefix patterns, and includes the platform-domain URL only while it is enabled. Cloudflare's separate `preview_urls` field is unsupported and rejected by the CLI. WDL consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, -`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes them from Wrangler's -temporary bundle config. `[ai]` is standard Wrangler configuration; WDL accepts -only its `binding` field and maps it into the WDL manifest. The other listed -shapes are WDL extensions. Other fields retain their existing Wrangler -passthrough behavior. Specific nested fields that WDL cannot represent are -rejected rather than silently dropped, including Cloudflare Artifacts -`triggers.events` subscriptions and R2 `local_dev.experimental_s3_credentials`. -`[wdl] session_policy` accepts `preserve` or `restart`. The default `preserve` -leaves loaded Durable Object facets on the version that built them until the -host actor restarts or the facet is deleted, and keeps established WebSockets -draining while their backend stays healthy. `restart` closes the worker's open -WebSockets with code `1012` at promotion and retires stale facets on their next -dispatch, preserving SQLite state. Wrangler's object-shaped declarative -`exports` config is unsupported. The dry-run child hides Wrangler's banner (and -its normal update check) and disables anonymous telemetry. Wrangler may still -consult the configured npm registry when reporting an unknown configuration -field; project build hooks retain their normal network access. For -`[[services]]` and `[[exports]]`, read `docs/deploy.md`: tenant JSRPC may -delegate service or Durable Object class stubs as opaque capabilities, but the -receiver cannot rewrite their host-authored caller properties. Keep delegated -stubs in memory; long-term irrevocable stub storage is unsupported. +`[[services]].ns`, and `[wdl]` itself and removes those WDL extensions from +Wrangler's temporary bundle config. `[ai]` is standard Wrangler configuration +and stays in that config for Wrangler validation. If a selected named +environment omits its own `ai`, the CLI warns that the top-level binding is not +inherited; WDL independently maps its `binding` into the WDL manifest. Other +fields retain their existing Wrangler passthrough behavior. Specific nested +fields that WDL cannot represent are rejected rather than silently dropped, +including Cloudflare Artifacts `triggers.events` subscriptions and R2 +`local_dev.experimental_s3_credentials`. `[wdl] session_policy` accepts +`preserve` or `restart`. The default `preserve` leaves loaded Durable Object +facets on the version that built them until the host actor restarts or the facet +is deleted, and keeps established WebSockets draining while their backend stays +healthy. `restart` closes the worker's open WebSockets with code `1012` at +promotion and retires stale facets on their next dispatch, preserving SQLite +state. Wrangler's object-shaped declarative `exports` config is unsupported. The +dry-run child hides Wrangler's banner (and its normal update check) and disables +anonymous telemetry. Wrangler may still consult the configured npm registry when +reporting an unknown configuration field; project build hooks retain their +normal network access. For `[[services]]` and `[[exports]]`, read +`docs/deploy.md`: tenant JSRPC may delegate service or Durable Object class +stubs as opaque capabilities, but the receiver cannot rewrite their +host-authored caller properties. Keep delegated stubs in memory; long-term +irrevocable stub storage is unsupported. Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it overrides the TCP target the admin token connects to (Host header + TLS SNI @@ -105,6 +107,18 @@ trusted projects. For a less-trusted or third-party project, recommend `--no-token-store` (or `WDL_TOKEN_STORE=off`) with an ephemeral `--token` / `--control-url`, rather than relying on the global store. +`wdl ai`, `wdl secret`, and `wdl token` redact invalid argument details. When a +string option precedes the complete subcommand path and its separate value is +also a command word, put the subcommand first or use `--flag=value`; for +example, use `wdl secret list --worker put` or `wdl secret --worker=put list` +for a worker named `put`. + +`wdl ai providers put` replaces the complete provider record and accepts only a +project-contained `{ kind, models }` file; omitted model aliases are removed. +When editing an existing provider, derive the file with +`wdl ai providers get --json | jq '.provider | {kind, models}'` +instead of feeding response-only fields back to Control. + Treat `wdl ai providers delete` as destructive: it removes both provider metadata and its credential and has no dry-run. Run `wdl config explain` first to confirm the resolved namespace, inspect the target with diff --git a/CHANGELOG.md b/CHANGELOG.md index 6eb58f7..d558e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,14 @@ - Add the `[ai]` binding, namespace provider and credential management commands, model discovery, OpenAI-compatible SDK guidance, and a Responses function-tool - agent example. + agent example protected by a Worker-level bearer token. + +### Changed + +- `wdl ai`, `wdl secret`, and `wdl token` now redact invalid argument details. + When a separate string option before the complete subcommand path has a value + that is also a command word, put the subcommand first or use `--flag=value` to + disambiguate it. ## 1.7.1 diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 9cc97af..29cc81b 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -91,6 +91,8 @@ CLI 只会从 `.env` 读取 WDL 平台变量:`ADMIN_TOKEN`、`CONTROL_URL`、` 推荐的做法是把这些凭证放进托管存储,而不是 shell export 或项目 `.env`:`wdl token set --ns --control-url ` 用隐藏输入读取 token、调 `/whoami` 校验后按 namespace 存入 `~/.config/wdl/credentials`(不进 shell 历史、也不落在项目文件里)。存储是优先级最低的层——命令行标志、shell env、项目 `.env` 仍然胜出——`wdl token list` / `wdl token rm` 管理它。第一个存入的 namespace 成为默认(一行 base `WDL_NS`,和项目 `.env` 一样),命令不带 `--ns` 也能跑;`wdl token use ` 切换默认。详见 [token-zh.md](./docs/token-zh.md)。 +`wdl ai`、`wdl secret` 和 `wdl token` 都可能接收凭据,因此会脱敏无效参数的细节。如果完整子命令路径前的 string option 使用分离式值,而该值本身也是命令词,请把子命令放到前面,或改用 `--flag=value` 消歧。例如写 `wdl secret list --worker put` 或 `wdl secret --worker=put list`,不要写 `wdl secret --worker put list`。 + `wdl deploy` 在上传前会以你的 OS 用户身份运行项目本地的 Wrangler dry-run 和 build 钩子,这些代码能读到磁盘上的 store(env scrub 只把 WDL 变量挡在 Wrangler 子进程的环境外,挡不住文件),所以只部署你信任的项目。`--no-token-store`(或 `WDL_TOKEN_STORE=off`)让 CLI 只从 flag / shell / `.env` 解析凭据、完全不读 store —— 这是给不太信任的项目或 CI 用的解析 opt-out,不是对文件本身的保护。 用 `wdl config explain` 查看最终 namespace、control URL、脱敏 token 以及每个值的来源。用 `wdl whoami` 调 control-plane `/whoami`,查看当前 authenticated principal、token id、platform version、最低支持 CLI version 和 URL hints。用 `wdl doctor` 做本地可用性检查,包括 Node.js、wdl-cli、Wrangler、配置文件是否存在、凭据是否能解析,以及 `/whoami` 是否可达;在 CI 里可加 `--strict`,命令仍会打印检查结果,但只要任一检查失败就以非零退出。当 control plane 暴露 `/whoami` 时,`doctor` 可以发现 token 是否有效、principal namespace、platform version 和 CLI compatibility;更细的 capability 检查仍需要额外的 control endpoint。运维方没有配置公开 platform domain 时,namespace URL 可能显示为 `(unavailable)`;认证和其它 `/whoami` 字段仍然有效。 @@ -264,7 +266,7 @@ Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canon | Analytics Engine | 暂不支持,部署时会拒绝 | | 其他未映射的 Wrangler 绑定/配置/策略段(例如 `vectorize`、`hyperdrive`、`agent_memory`、`websearch`、`media`、`stream`、`ratelimits`、`vpc_services`、`cloudchamber`、`containers`、`wasm_modules`、`[site]`、`limits`、`placement`、`observability`、`pages_build_output_dir`) | 不支持;部署时显式报错,不会静默丢弃绑定/配置。CLI 报错会点名被拒字段;内部拒绝列表跟随打包的 Wrangler schema,这里不复刻完整清单 | -WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns`、`[ai]` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除它们。`[ai]` 是 Wrangler 标准配置;WDL 只接受其中的 `binding` 字段,并把该声明映射到 WDL manifest。面向租户的数组形态 `[[exports]]` 和其余这些字段是 WDL 扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 +WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些 WDL 扩展。`[ai]` 是 Wrangler 标准配置,会保留在临时配置中供 Wrangler 校验;如果选中的 named environment 没有自己的 `ai`,CLI 会提示顶层 binding 不会继承。WDL 另行只接受其中的 `binding` 字段,并把该声明映射到 WDL manifest。其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 Cron triggers 和 queue consumers 是运行时 dispatch 能力。除非管理方明确给了 reserved namespace,否则只应声明在 tenant namespace 里的可路由 Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是公开/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 @@ -278,7 +280,7 @@ R2 object key 可以包含开头、结尾或连续的 `/` 分隔符;CLI 会保 ### 环境覆盖 -如果 Wrangler 配置里有 `[env.]`,必须通过 `--env ` 或 `CLOUDFLARE_ENV` 显式选择;CLI 不会自动挑一个默认环境。和 Cloudflare Workers / Wrangler 不同,WDL 不会把环境名追加到 worker / script 名后面:`wdl deploy . --env preview` 仍然更新顶层 `name` 指定的 worker。`vars` 和大部分 bindings 仍是 env-scoped / non-inheritable:选中 env 后,顶层 `[vars]`、KV、D1、R2、AI、queues、services、workflows 都不会自动进入该 env。策略类配置则会继承:`workers_dev`、`route` / `routes` 和 `[wdl]` 在 env 没有自己声明时继续生效。需要同时跑 staging / production 时,默认用不同 namespace 区分,除非管理方另有约定。 +如果 Wrangler 配置里有 `[env.]`,必须通过 `--env ` 或 `CLOUDFLARE_ENV` 显式选择;CLI 不会自动挑一个默认环境。和 Cloudflare Workers / Wrangler 不同,WDL 不会把环境名追加到 worker / script 名后面:`wdl deploy . --env preview` 仍然更新顶层 `name` 指定的 worker。`vars` 和大部分 bindings 仍是 env-scoped / non-inheritable:选中 env 后,顶层 `[vars]`、KV、D1、R2、AI、queues、services、workflows 都不会自动进入该 env;如果选中的 env 漏掉顶层 `[ai]` binding,deploy 会明确提示。策略类配置则会继承:`workers_dev`、`route` / `routes` 和 `[wdl]` 在 env 没有自己声明时继续生效。需要同时跑 staging / production 时,默认用不同 namespace 区分,除非管理方另有约定。 ### KV @@ -379,6 +381,15 @@ wdl ai models Provider 元数据选择官方 adapter(`openai`、`xai` 或 `deepseek`),并把 `openai/primary` 这类有界 alias 映射到原生 model id 和 capability。更新元数据会生成新 revision;同 kind 更新保留既有 credential,切换 adapter kind 才会清除 credential 并要求在推理前重新配置。凭据是加密的 namespace 资源,不会进入 Worker env 或 bundle metadata。 +`providers put` 会整条替换 `{ kind, models }` metadata,省略的 alias 会被删除。输入文件必须留在当前项目目录内;编辑 `providers get --json` 的响应前,先提取可写字段: + +```bash +wdl ai providers get openai --json \ + | jq '.provider | {kind, models}' > provider.openai.json +$EDITOR provider.openai.json +wdl ai providers put openai --file provider.openai.json +``` + `wdl ai providers delete ` 默认会提示确认,并同时删除 provider metadata 和 credential。该命令没有 dry-run。先运行 `wdl config explain` 确认最终解析出的 namespace,再用 `wdl ai providers get --ns ` 查看目标,并在删除时传入同一个显式 `--ns`。只有完成这项独立检查并与用户确认后,才能传 `--yes`。 Agent 代码使用 provider 原生 Responses 形态: diff --git a/GUIDE.md b/GUIDE.md index 7426723..b68e901 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -137,6 +137,13 @@ shell env, and a project `.env` still win — and `wdl token list` / `WDL_NS`, like a project `.env`'s), so commands run without `--ns`; `wdl token use ` switches it. See [token.md](./docs/token.md). +Because `wdl ai`, `wdl secret`, and `wdl token` can receive credentials, they +redact invalid argument details. If a string option appears before the complete +subcommand path and its separate value is also a command word, put the +subcommand first or use `--flag=value`; for example, write +`wdl secret list --worker put` or `wdl secret --worker=put list`, not +`wdl secret --worker put list`. + `wdl deploy` runs the project's local Wrangler dry-run and build hooks as your OS user before uploading, and that code can read the on-disk store (the env scrub keeps WDL variables out of the Wrangler child's environment, not out of @@ -394,11 +401,12 @@ with `worker_env_too_large`. | Other unmapped Wrangler binding/config/policy sections (for example `vectorize`, `hyperdrive`, `agent_memory`, `websearch`, `media`, `stream`, `ratelimits`, `vpc_services`, `cloudchamber`, `containers`, `wasm_modules`, `[site]`, `limits`, `placement`, `observability`, `pages_build_output_dir`) | Not supported; deploy fails loudly instead of silently dropping the binding/config. The CLI error names the rejected field; the internal rejection list tracks the bundled Wrangler schema and is not reproduced exhaustively here | WDL consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, -`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes them from the +`[[services]].ns`, and `[wdl]` itself and removes those WDL extensions from the temporary config passed to the Wrangler bundler. `[ai]` is standard Wrangler -configuration; WDL accepts only its `binding` field and maps that declaration -into the WDL manifest. The tenant-facing array-shaped `[[exports]]` and the -other listed fields are WDL extensions. Other fields retain their existing +configuration and stays in that temporary config for Wrangler validation. When a +selected named environment omits its own `ai`, the CLI warns that the top-level +binding is not inherited; WDL independently accepts only its `binding` field and +maps that declaration into the WDL manifest. Other fields retain their existing Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` configuration is not supported by WDL. @@ -443,9 +451,10 @@ append the environment name to the worker / script name: `wdl deploy . --env preview` still updates the top-level `name`. `vars` and most bindings remain env-scoped and non-inheritable: selecting an env does not carry top-level `[vars]`, KV, D1, R2, AI, queues, services, or workflows into that -env. Policies do inherit: `workers_dev`, `route` / `routes`, and `[wdl]` keep -applying unless the env declares its own. For staging and production side by -side, use separate namespaces unless your operator tells you otherwise. +env. Deploy warns when a top-level `[ai]` binding is omitted from the selected +environment. Policies do inherit: `workers_dev`, `route` / `routes`, and `[wdl]` +keep applying unless the env declares its own. For staging and production side +by side, use separate namespaces unless your operator tells you otherwise. ### KV @@ -574,6 +583,17 @@ preserve an existing credential; changing the adapter kind clears it and requires `credential put` before inference. Credentials are encrypted namespace resources and never enter Worker env or bundle metadata. +`providers put` replaces the complete `{ kind, models }` metadata record, so an +omitted alias is removed. The input file must stay inside the current project; +extract writable fields before editing a `providers get --json` response: + +```bash +wdl ai providers get openai --json \ + | jq '.provider | {kind, models}' > provider.openai.json +$EDITOR provider.openai.json +wdl ai providers put openai --file provider.openai.json +``` + `wdl ai providers delete ` prompts by default and deletes both the provider metadata and its credential. It has no dry-run. First run `wdl config explain` to confirm the resolved namespace, then inspect the target diff --git a/commands/ai.js b/commands/ai.js index 8c191c2..4f8b09d 100644 --- a/commands/ai.js +++ b/commands/ai.js @@ -9,9 +9,12 @@ import { isMain, isPathInside, optionHelp, - unexpectedArgument, + readJsonOrFailWithHint, + redactedArgumentError, + sensitiveInputArgumentError, } from "../lib/common.js"; import { escapeTerminalText, writeJsonOr, writeStatusLine } from "../lib/output.js"; +import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; import { confirmAction, readSecretStdin } from "../lib/stdin.js"; const AI_OPTIONS = [ @@ -27,6 +30,16 @@ const command = defineCommand({ name: "ai", summary: "Manage AI providers, credentials, and models.", options: AI_OPTIONS, + sensitiveInput: { + commandPaths: [ + ["models"], + ["providers", "list"], + ["providers", "get"], + ["providers", "put"], + ["providers", "delete"], + ["credential", "put"], + ], + }, usage: usageText, run: runAi, }); @@ -51,7 +64,7 @@ async function runAi({ values, positionals, context }) { if (!group || !ns) throw new CliError(usageText()); if (group === "models") { - if (action) throw unexpectedArgument("ai models", action); + if (action) throw redactedArgumentError("ai models"); const { headers } = context.resolveControl(); const body = /** @type {AiModelsResponse} */ ( await context.fetchJson(context.nsUrl("ai", "models"), { headers }, "list AI models") @@ -66,14 +79,14 @@ async function runAi({ values, positionals, context }) { const transports = Array.isArray(model.transports) ? model.transports.join(",") : "-"; writeStatusLine( stdout, - `${escapeTerminalText(String(model.id ?? "-"))} protocol=${escapeTerminalText(String(model.protocol ?? "-"))} transports=${escapeTerminalText(transports)}` + `${String(model.id ?? "-")} protocol=${String(model.protocol ?? "-")} transports=${transports}` ); } return; } if (group === "providers" && action === "list") { - if (provider) throw unexpectedArgument("ai providers list", provider); + if (provider) throw redactedArgumentError("ai providers list"); const { headers } = context.resolveControl(); const body = /** @type {AiProvidersResponse} */ ( await context.fetchJson(context.nsUrl("ai", "providers"), { headers }, "list AI providers") @@ -88,7 +101,7 @@ async function runAi({ values, positionals, context }) { const modelCount = entry.models && typeof entry.models === "object" ? Object.keys(entry.models).length : 0; writeStatusLine( stdout, - `${escapeTerminalText(String(entry.name ?? "-"))} kind=${escapeTerminalText(String(entry.kind ?? "-"))} models=${modelCount} credential=${entry.credentialConfigured === true ? "configured" : "missing"}` + `${String(entry.name ?? "-")} kind=${String(entry.kind ?? "-")} models=${modelCount} credential=${entry.credentialConfigured === true ? "configured" : "missing"}` ); } return; @@ -96,27 +109,27 @@ async function runAi({ values, positionals, context }) { if (group === "providers" && action === "get") { requireProvider(provider, "ai providers get"); - if (extraArg) throw unexpectedArgument("ai providers get", extraArg); + if (extraArg) throw redactedArgumentError("ai providers get"); const { headers } = context.resolveControl(); const body = /** @type {AiProviderResponse} */ ( await context.fetchJson(context.nsUrl("ai", "providers", provider), { headers }, "get AI provider") ); if (writeJsonOr(values.json === true, body, stdout)) return; const entry = body.provider; - writeStatusLine(stdout, `name: ${escapeTerminalText(String(entry?.name ?? provider))}`); - writeStatusLine(stdout, `kind: ${escapeTerminalText(String(entry?.kind ?? "-"))}`); - writeStatusLine(stdout, `revision: ${escapeTerminalText(String(entry?.revision ?? "-"))}`); + writeStatusLine(stdout, `name: ${String(entry?.name ?? provider)}`); + writeStatusLine(stdout, `kind: ${String(entry?.kind ?? "-")}`); + writeStatusLine(stdout, `revision: ${String(entry?.revision ?? "-")}`); writeStatusLine(stdout, `credential: ${entry?.credentialConfigured === true ? "configured" : "missing"}`); for (const [alias, descriptor] of Object.entries(entry?.models ?? {})) { const protocol = descriptor && typeof descriptor === "object" ? descriptor.protocol : undefined; - writeStatusLine(stdout, `model: ${escapeTerminalText(alias)} (${escapeTerminalText(String(protocol ?? "-"))})`); + writeStatusLine(stdout, `model: ${alias} (${String(protocol ?? "-")})`); } return; } if (group === "providers" && action === "put") { requireProvider(provider, "ai providers put"); - if (extraArg) throw unexpectedArgument("ai providers put", extraArg); + if (extraArg) throw redactedArgumentError("ai providers put"); const providerBody = readProviderFile(values.file, context.cwd); const { headers } = context.resolveControl(); const body = /** @type {AiProviderResponse} */ ( @@ -135,13 +148,13 @@ async function runAi({ values, positionals, context }) { body.provider?.credentialConfigured === true ? "existing credential preserved" : "credential not configured; configure it before use"; - writeStatusLine(stdout, `OK AI provider ${escapeTerminalText(provider)} saved; ${credentialStatus}`); + writeStatusLine(stdout, `OK AI provider ${provider} saved; ${credentialStatus}`); return; } if (group === "providers" && action === "delete") { requireProvider(provider, "ai providers delete"); - if (extraArg) throw unexpectedArgument("ai providers delete", extraArg); + if (extraArg) throw redactedArgumentError("ai providers delete"); const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, @@ -161,18 +174,24 @@ async function runAi({ values, positionals, context }) { writeStatusLine( stdout, body.deleted === true - ? `OK AI provider ${escapeTerminalText(provider)} and its credential deleted` - : `(AI provider ${escapeTerminalText(provider)} was not configured)` + ? `OK AI provider ${provider} and its credential deleted` + : `(AI provider ${provider} was not configured)` ); return; } if (group === "credential" && action === "put") { requireProvider(provider, "ai credential put"); - if (extraArg) throw unexpectedArgument("ai credential put", extraArg); + if (extraArg) throw sensitiveInputArgumentError("ai credential put"); const { headers } = context.resolveControl(); const providerBody = /** @type {AiProviderResponse} */ ( - await context.fetchJson(context.nsUrl("ai", "providers", provider), { headers }, "get AI provider") + await fetchAiJsonWithHint( + context, + context.nsUrl("ai", "providers", provider), + { headers }, + "prepare AI credential", + aiCredentialPreflightHint + ) ); const revision = providerBody.provider?.revision; if (typeof revision !== "string" || !revision) { @@ -183,21 +202,27 @@ async function runAi({ values, positionals, context }) { stderr, }); if (!credential) throw new CliError("AI credential must not be empty"); - const body = await context.fetchJson( + const body = await fetchAiJsonWithHint( + context, context.nsUrl("ai", "providers", provider, "credential"), { method: "PUT", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify({ revision, credential }), }, - "put AI credential" + "put AI credential", + aiCredentialMutationHint ); if (writeJsonOr(values.json === true, body, stdout)) return; - writeStatusLine(stdout, `OK AI credential configured for ${escapeTerminalText(provider)}`); + writeStatusLine(stdout, `OK AI credential configured for ${provider}`); return; } - throw new CliError(`unknown ai command: ${escapeTerminalText(positionals.join(" "))}\n${usageText()}`); + if (group === "credential") { + throw new CliError(`unknown ai credential command; expected "put"\n${usageText()}`); + } + + throw new CliError(`unknown ai command\n${usageText()}`); } /** @param {string | undefined} provider @param {string} commandName */ @@ -233,6 +258,37 @@ function readProviderFile(file, cwd) { return parsed; } +/** + * @param {import("../lib/command.js").CommandContext} context + * @param {string} url + * @param {import("../lib/control-fetch.js").ControlFetchInit} init + * @param {string} label + * @param {(error: unknown) => string} errorHint + */ +async function fetchAiJsonWithHint(context, url, init, label, errorHint) { + const res = await context.controlFetch(url, { ...init, env: init.env ?? context.env }); + return await readJsonOrFailWithHint(res, label, errorHint); +} + +/** @param {unknown} error */ +function aiCredentialPreflightHint(error) { + if (error === "ai_provider_not_found") { + return "; create the provider with `wdl ai providers put` before configuring its credential."; + } + return ""; +} + +/** @param {unknown} error */ +function aiCredentialMutationHint(error) { + if (error === "ai_provider_revision_mismatch") { + return "; credential was not written. Provider metadata changed while input was being entered; rerun this command."; + } + if (error === "ai_credential_encryption_unavailable" || isSecretEnvelopeErrorCode(error)) { + return "; credential was not written. Secret-envelope configuration or stored secret data needs operator repair before retrying."; + } + return ""; +} + /** @typedef {{ providers?: Array<{ name?: unknown, kind?: unknown, models?: unknown, credentialConfigured?: unknown }> }} AiProvidersResponse */ /** @typedef {{ provider?: { name?: unknown, kind?: unknown, revision?: unknown, models?: Record, credentialConfigured?: unknown } }} AiProviderResponse */ /** @typedef {{ models?: Array<{ id?: unknown, protocol?: unknown, transports?: unknown }> }} AiModelsResponse */ diff --git a/commands/secret.js b/commands/secret.js index 32a41cd..c9f8aa5 100644 --- a/commands/secret.js +++ b/commands/secret.js @@ -7,15 +7,15 @@ import { CliError, defineCliOption, formatHelp, - formatHttpError, isMain, isNonEmptyString, optionHelp, - readJsonOrFail, - unexpectedArgument, + readJsonOrFailWithHint, + redactedArgumentError, + sensitiveInputArgumentError, } from "../lib/common.js"; import { confirmAction, readSecretStdin } from "../lib/stdin.js"; -import { escapeTerminalText, writeJsonOr, writeStatusLine } from "../lib/output.js"; +import { writeJsonOr, writeStatusLine } from "../lib/output.js"; import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; const SECRET_OPTIONS = [ @@ -32,6 +32,7 @@ const command = defineCommand({ name: "secret", summary: "Manage namespace-level or worker-level secrets.", options: SECRET_OPTIONS, + sensitiveInput: { commandPaths: [["list"], ["put"], ["delete"]] }, usage: usageText, run: runSecret, }); @@ -80,7 +81,7 @@ async function runSecret({ values, positionals, context }) { const scopeLabel = worker ? `${ns}/${worker}` : `${ns} (ns)`; if (subcommand === "list") { - if (keyArg) throw unexpectedArgument("secret list", keyArg); + if (keyArg) throw redactedArgumentError("secret list"); const { headers } = context.resolveControl(); const body = /** @type {SecretResponse} */ ( await context.fetchJson(context.nsUrl(...secretPath), { headers }, "list") @@ -94,7 +95,7 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "put") { if (!keyArg) throw new CliError("put requires a KEY argument"); - if (extraArg) throw unexpectedArgument("secret put", extraArg); + if (extraArg) throw sensitiveInputArgumentError("secret put"); const { headers } = context.resolveControl(); // Empty string is a set secret (≠ unset), matching wrangler. const value = await readSecretStdin(stdin, { @@ -126,7 +127,7 @@ async function runSecret({ values, positionals, context }) { if (subcommand === "delete") { if (!keyArg) throw new CliError("delete requires a KEY argument"); - if (extraArg) throw unexpectedArgument("secret delete", extraArg); + if (extraArg) throw redactedArgumentError("secret delete"); const { headers } = context.resolveControl(); await confirmAction({ yes: values.yes === true, @@ -156,7 +157,7 @@ async function runSecret({ values, positionals, context }) { return; } - throw new CliError(`unknown subcommand: ${escapeTerminalText(subcommand)}`); + throw new CliError(`unknown secret subcommand\n${usageText()}`); } /** @@ -167,22 +168,11 @@ async function runSecret({ values, positionals, context }) { */ async function fetchSecretMutationJson(context, url, init, label) { const res = await context.controlFetch(url, { ...init, env: init.env ?? context.env }); - if (res.ok) return await readJsonOrFail(res, label); - const text = await res.text(); - throw new CliError(`${label} failed: ${formatHttpError(res.status, text, res.headers)}${secretMutationHint(text)}`); + return await readJsonOrFailWithHint(res, label, secretMutationHint); } -/** @param {string} text */ -function secretMutationHint(text) { - /** @type {unknown} */ - let body; - try { - body = JSON.parse(text); - } catch { - return ""; - } - if (!body || typeof body !== "object" || Array.isArray(body)) return ""; - const error = /** @type {{ error?: unknown }} */ (body).error; +/** @param {unknown} error */ +function secretMutationHint(error) { if (error === "worker_env_too_large") { return "; secret mutation was not written. Reduce [vars], secrets, or binding metadata; if source_version names a retained version, redeploy/delete that version. estimated_version may be a sizing placeholder. Namespace-scope mutations can be blocked by another worker's retained metadata."; } diff --git a/commands/token.js b/commands/token.js index f6d3313..d8f4a3f 100644 --- a/commands/token.js +++ b/commands/token.js @@ -38,6 +38,7 @@ const command = defineCommand({ name: "token", summary: "Store, list, switch the default for, and remove control-plane tokens locally.", options: TOKEN_OPTIONS, + sensitiveInput: { commandPaths: [["set"], ["list"], ["use"], ["rm"]] }, autoloadEnv: false, usage: usageText, run: runToken, diff --git a/docs/ai-zh.md b/docs/ai-zh.md index 0c03991..87e7c2e 100644 --- a/docs/ai-zh.md +++ b/docs/ai-zh.md @@ -61,6 +61,15 @@ wdl ai models --ns `providers put` 会生成新的 provider revision。同一官方 adapter kind 内更新时保留既有 credential;切换 kind 时清除 credential,新建 provider 则默认没有 credential。返回的 provider 状态显示 credential 缺失时,才需要执行 `credential put`。凭据从隐藏 TTY 输入或 stdin 读取;CLI 不提供命令行 credential flag,list/get 也绝不返回凭据值。官方 provider credential 必须是不含空白的 visible-ASCII bearer token。 +`providers put` 会整条替换 provider metadata;`models` 中省略的 alias 会被删除。`--file` 必须留在当前项目目录内,并且只能包含可写的 `{ kind, models }` 形状。不要把 `providers get --json` 的结果原样回灌,因为 `name`、`revision` 和 `credentialConfigured` 是只读响应字段。编辑既有 provider 时使用: + +```bash +wdl ai providers get openai --json --ns \ + | jq '.provider | {kind, models}' > provider.openai.json +$EDITOR provider.openai.json +wdl ai providers put openai --file provider.openai.json --ns +``` + 支持的 provider kind 与规范上游范围: | `kind` | HTTP 协议 | WebSocket 协议 | @@ -82,6 +91,8 @@ wdl ai providers delete [--yes] [--json] wdl ai models [--json] ``` +`wdl ai` 会脱敏无效参数的细节,因为用户可能已经把 credential 误贴进命令行。如果完整子命令路径前的 string option 使用分离式值,而该值本身也是 AI 命令词,请把完整子命令路径放到前面,或改用 `--ns=models`、`--file=put` 这类 inline 形式。 + Model list 包含全部已配置的 provider metadata,不按 credential 状态过滤;所选 provider 缺少 credential 时,推理仍会 fail closed。 `wdl ai models` 读取 Control 的当前权威状态。一个已加载 Worker 内,`env.AI.models()` 与 `run()` 在该 module 生命周期中共享一份延迟加载的 catalog snapshot;alias、protocol、transport、modality 和 capability 变更要等 module 重载或重新部署后才可见。Credential 变化和 upstream model 轮换会在每次推理时重新解析,无需 redeploy;补上缺失 credential 后,下一次调用也会立即生效。 @@ -202,14 +213,20 @@ WDL 当前不提供托管模型凭据、持久用量统计、消费 quota、AI G ## 端到端示例 -`../examples/ai-agent-demo` 展示 Responses function-tool 循环。写入 provider 文件、配置凭据并部署 Worker 后,再 POST prompt: +`../examples/ai-agent-demo` 展示由 bearer token 保护的 Responses function-tool 循环。写入 provider 文件,配置 provider credential 和 demo 的 Worker 级访问 token 并部署 Worker 后,再 POST prompt: ```bash cd examples/ai-agent-demo wdl ai providers put openai --file provider.openai.json --ns printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai --ns +AI_DEMO_TOKEN="$(openssl rand -hex 32)" +printf '%s' "$AI_DEMO_TOKEN" | wdl secret put --worker ai-agent-demo AI_DEMO_TOKEN --ns wdl deploy . --ns -curl -X POST -H 'content-type: application/json' \ - -d '{"prompt":"What time is it in Asia/Tokyo?"}' \ - https://./ai-agent-demo/ +printf 'authorization: Bearer %s\n' "$AI_DEMO_TOKEN" | + curl -X POST -H @- \ + -H 'content-type: application/json' \ + -d '{"prompt":"What time is it in Asia/Tokyo?"}' \ + https://./ai-agent-demo/ ``` + +未配置 `AI_DEMO_TOKEN` 时,demo 会 fail closed。它是应用访问 token,与 namespace provider credential 相互独立;对外开放基于该示例修改的 Worker 前,应换成应用自身的正式鉴权。 diff --git a/docs/ai.md b/docs/ai.md index 10ad7e2..36eb8f6 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -82,6 +82,19 @@ from hidden TTY input or stdin; there is no command-line credential flag and credential values are never returned by list/get commands. Official provider credentials must be visible-ASCII bearer tokens without whitespace. +`providers put` replaces the complete provider metadata record; an alias omitted +from `models` is removed. Its `--file` must stay inside the current project +directory and must contain only the writable `{ kind, models }` shape. Do not +pass `providers get --json` back unchanged because `name`, `revision`, and +`credentialConfigured` are response-only fields. To edit an existing provider: + +```bash +wdl ai providers get openai --json --ns \ + | jq '.provider | {kind, models}' > provider.openai.json +$EDITOR provider.openai.json +wdl ai providers put openai --file provider.openai.json --ns +``` + Supported provider kinds and canonical upstream ownership: | `kind` | HTTP protocols | WebSocket protocols | @@ -105,6 +118,12 @@ wdl ai providers delete [--yes] [--json] wdl ai models [--json] ``` +Invalid `wdl ai` argument details are redacted because a credential may have +been pasted into the command line. If a string option before the complete +subcommand path has a separate value that is also an AI command word, put the +subcommand path first or use the inline form, such as `--ns=models` or +`--file=put`. + The model list contains configured provider metadata regardless of credential status. Inference still fails closed until the selected provider has a credential. @@ -264,15 +283,24 @@ Responses/webhooks, provider file APIs, WebRTC, or SIP. ## End-to-end example -`../examples/ai-agent-demo` demonstrates a Responses function-tool loop. Put the -provider file, configure its credential, deploy the Worker, then POST a prompt: +`../examples/ai-agent-demo` demonstrates a Responses function-tool loop behind a +bearer token. Put the provider file, configure its credential and the demo's +Worker-level access token, deploy the Worker, then POST a prompt: ```bash cd examples/ai-agent-demo wdl ai providers put openai --file provider.openai.json --ns printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai --ns +AI_DEMO_TOKEN="$(openssl rand -hex 32)" +printf '%s' "$AI_DEMO_TOKEN" | wdl secret put --worker ai-agent-demo AI_DEMO_TOKEN --ns wdl deploy . --ns -curl -X POST -H 'content-type: application/json' \ - -d '{"prompt":"What time is it in Asia/Tokyo?"}' \ - https://./ai-agent-demo/ +printf 'authorization: Bearer %s\n' "$AI_DEMO_TOKEN" | + curl -X POST -H @- \ + -H 'content-type: application/json' \ + -d '{"prompt":"What time is it in Asia/Tokyo?"}' \ + https://./ai-agent-demo/ ``` + +The demo fails closed when `AI_DEMO_TOKEN` is absent. It is an application +access token, separate from the namespace provider credential; replace it with +the application's real authentication before exposing a derived Worker. diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index c0cb5e5..5870543 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -125,7 +125,7 @@ wdl deploy . --env production **支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[ai]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`route` / `routes`、`workers_dev`、`[wdl] session_policy`、`[env.]`。 -WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns`、`[ai]` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除它们。`[ai]` 是 Wrangler 标准配置;WDL 只接受其中的 `binding` 字段,并把该声明映射到 WDL manifest。面向租户的数组形态 `[[exports]]` 和其余这些字段是 WDL 扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。`[wdl] session_policy` 见上面的会话策略一节。 +WDL 会自行消费 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些 WDL 扩展。`[ai]` 是 Wrangler 标准配置,会保留在临时配置中供 Wrangler 校验;如果选中的 named environment 没有自己的 `ai`,CLI 会提示顶层 binding 不会继承。WDL 另行只接受其中的 `binding` 字段,并把该声明映射到 WDL manifest。其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。`[wdl] session_policy` 见上面的会话策略一节。 WDL 还会拒绝 Cloudflare Artifacts `triggers.events` subscription 和 R2 `local_dev.experimental_s3_credentials`;这两个字段都没有对应的 WDL deploy manifest 或 runtime 映射。 diff --git a/docs/deploy.md b/docs/deploy.md index 2afc579..c654882 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -240,11 +240,12 @@ timezone, a platform extension), `[[queues.producers]]` / `route` / `routes`, `workers_dev`, `[wdl] session_policy`, `[env.]`. WDL consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, -`[[services]].ns`, `[ai]`, and `[wdl]` itself and removes them from the +`[[services]].ns`, and `[wdl]` itself and removes those WDL extensions from the temporary config passed to the Wrangler bundler. `[ai]` is standard Wrangler -configuration; WDL accepts only its `binding` field and maps that declaration -into the WDL manifest. The tenant-facing array-shaped `[[exports]]` and the -other listed fields are WDL extensions. Other fields retain their existing +configuration and stays in that temporary config for Wrangler validation. When a +selected named environment omits its own `ai`, the CLI warns that the top-level +binding is not inherited; WDL independently accepts only its `binding` field and +maps that declaration into the WDL manifest. Other fields retain their existing Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` configuration is not supported by WDL. `[wdl] session_policy` has its own section above. diff --git a/docs/env-overrides-zh.md b/docs/env-overrides-zh.md index 1b4011c..933819c 100644 --- a/docs/env-overrides-zh.md +++ b/docs/env-overrides-zh.md @@ -75,7 +75,7 @@ wdl deploy . --env production Cloudflare Workers / Wrangler 的 `--env preview` 通常会发布带环境后缀的 worker / script 名。WDL 不会这样做:`wdl deploy . --env preview` 和 `wdl deploy . --env production` 都更新顶层 `name` 指定的同一个 worker。要部署两个独立 worker,请用两个不同的顶层 `name`、两个目录,或两个 namespace。 -`vars` 和大部分 bindings 仍按 Wrangler 的 non-inheritable 心智模型处理:选中 `[env.]` 后,顶层 `[vars]`、KV、D1、R2、queues、services、workflows、AI 等不会自动继承到该 env。需要某个 runtime env 变量或 binding 时,要在对应的 `[env.]` 里重新声明。 +`vars` 和大部分 bindings 仍按 Wrangler 的 non-inheritable 心智模型处理:选中 `[env.]` 后,顶层 `[vars]`、KV、D1、R2、queues、services、workflows、AI 等不会自动继承到该 env。需要某个 runtime env 变量或 binding 时,要在对应的 `[env.]` 里重新声明;如果选中的 env 没有重新声明顶层 `[ai]` binding,deploy 会明确提示。 按上面的例子: diff --git a/docs/env-overrides.md b/docs/env-overrides.md index d1275ce..dfa1465 100644 --- a/docs/env-overrides.md +++ b/docs/env-overrides.md @@ -91,7 +91,8 @@ use two different top-level `name` values, two directories, or two namespaces. once `[env.]` is selected, top-level `[vars]`, KV, D1, R2, queues, services, workflows, AI, etc. do not inherit into that env automatically. When a runtime env var or binding is needed, redeclare it inside the matching -`[env.]`. +`[env.]`. Deploy emits a warning when a top-level `[ai]` binding is not +redeclared in the selected environment. With the example above: diff --git a/docs/secrets-zh.md b/docs/secrets-zh.md index 3941d03..ff89e3b 100644 --- a/docs/secrets-zh.md +++ b/docs/secrets-zh.md @@ -26,6 +26,8 @@ printf '%s' "$VAL" | wdl secret put --scope ns KEY 用 `printf '%s'`(不要用 `echo`),避免在密钥值末尾带上换行符。 +`wdl secret` 会脱敏无效参数的细节。如果子命令前的 string option 使用分离式值,且该值是 `list`、`put` 或 `delete`,请把子命令放到前面,或改用 inline 形式。对于名为 `put` 的 worker,写 `wdl secret list --worker put` 或 `wdl secret --worker=put list`;分离式的 `wdl secret --worker put list` 会因歧义而被拒绝。 + ## 列举与删除 ```bash diff --git a/docs/secrets.md b/docs/secrets.md index fcb596f..cc5ad40 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -32,6 +32,12 @@ printf '%s' "$VAL" | wdl secret put --scope ns KEY Use `printf '%s'` (not `echo`) to avoid a trailing newline at the end of the secret value. +Invalid `wdl secret` argument details are redacted. If a string option before +the subcommand has a separate value equal to `list`, `put`, or `delete`, put the +subcommand first or use the inline form. For a worker named `put`, write +`wdl secret list --worker put` or `wdl secret --worker=put list`; the separated +`wdl secret --worker put list` form is rejected as ambiguous. + ## List and delete ```bash diff --git a/docs/token-zh.md b/docs/token-zh.md index ec2fb86..45de0b1 100644 --- a/docs/token-zh.md +++ b/docs/token-zh.md @@ -43,6 +43,8 @@ wdl token use acme wdl token rm --ns acme ``` +`wdl token` 会脱敏无效参数的细节。如果子命令前的 string option 使用分离式值,且该值是 `set`、`list`、`use` 或 `rm`,请把子命令放到前面,或改用 `--flag=value`;例如在该位置引用字面名为 `list` 的 namespace 时使用 `--ns=list`。 + ## 在解析链中的位置 存储是优先级最低的凭证层: diff --git a/docs/token.md b/docs/token.md index e771636..017451e 100644 --- a/docs/token.md +++ b/docs/token.md @@ -55,6 +55,11 @@ wdl token use acme wdl token rm --ns acme ``` +Invalid `wdl token` argument details are redacted. If a string option before the +subcommand has a separate value equal to `set`, `list`, `use`, or `rm`, put the +subcommand first or use `--flag=value`; for example, use `--ns=list` for a +namespace literally named `list` in that position. + ## Where it sits in resolution The store is the lowest-precedence credential layer: diff --git a/examples/ai-agent-demo/src/index.js b/examples/ai-agent-demo/src/index.js index 5931dc1..f0d11bf 100644 --- a/examples/ai-agent-demo/src/index.js +++ b/examples/ai-agent-demo/src/index.js @@ -14,6 +14,7 @@ const TIME_TOOL = { }; const MAX_TOOL_ROUNDS = 8; +const encoder = new TextEncoder(); function json(value, init = {}) { const headers = new Headers(init.headers); @@ -29,15 +30,43 @@ function responseText(response) { .join(""); } +async function hasValidBearerToken(request, expected) { + const authorization = request.headers.get("authorization"); + const provided = /^Bearer +([A-Za-z0-9._~+/-]+=*)$/i.exec(authorization ?? "")?.[1] ?? ""; + const [providedHash, expectedHash] = await Promise.all([ + crypto.subtle.digest("SHA-256", encoder.encode(provided)), + crypto.subtle.digest("SHA-256", encoder.encode(expected)), + ]); + return crypto.subtle.timingSafeEqual(providedHash, expectedHash); +} + function executeTool(call) { - if (call.name !== "get_time") throw new Error(`unsupported tool: ${call.name}`); - const args = JSON.parse(call.arguments); - const value = new Intl.DateTimeFormat("en-GB", { - dateStyle: "full", - timeStyle: "long", - timeZone: args.timezone, - }).format(new Date()); - return { type: "function_call_output", call_id: call.call_id, output: JSON.stringify({ value }) }; + if (typeof call.call_id !== "string" || !call.call_id) throw new Error("function call is missing call_id"); + try { + if (call.name !== "get_time") throw new Error(`unsupported tool: ${call.name}`); + if (typeof call.arguments !== "string") throw new Error("invalid get_time arguments"); + const args = JSON.parse(call.arguments); + if ( + !args || + typeof args !== "object" || + Array.isArray(args) || + Object.keys(args).length !== 1 || + !Object.hasOwn(args, "timezone") || + typeof args.timezone !== "string" || + !args.timezone.trim() + ) { + throw new Error("get_time requires exactly one non-empty string timezone"); + } + const value = new Intl.DateTimeFormat("en-GB", { + dateStyle: "full", + timeStyle: "long", + timeZone: args.timezone, + }).format(new Date()); + return { type: "function_call_output", call_id: call.call_id, output: JSON.stringify({ value }) }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { type: "function_call_output", call_id: call.call_id, output: JSON.stringify({ error: message }) }; + } } async function runAgent(ai, prompt) { @@ -61,10 +90,19 @@ async function runAgent(ai, prompt) { export default { async fetch(request, env) { + if (typeof env.AI_DEMO_TOKEN !== "string" || !env.AI_DEMO_TOKEN) { + return json({ error: "demo_not_configured", message: "AI_DEMO_TOKEN is not configured" }, { status: 503 }); + } + if (!(await hasValidBearerToken(request, env.AI_DEMO_TOKEN))) { + return json( + { error: "unauthorized" }, + { status: 401, headers: { "www-authenticate": 'Bearer realm="ai-agent-demo"' } } + ); + } if (request.method === "GET") { return json({ worker: "ai-agent-demo", - usage: "POST JSON { prompt }", + usage: "POST JSON { prompt } with Authorization: Bearer ", models: await env.AI.models(), }); } diff --git a/lib/command.js b/lib/command.js index 169ce3a..9b9c134 100644 --- a/lib/command.js +++ b/lib/command.js @@ -18,6 +18,7 @@ import { optionParseOptions, printHelpIfRequested, readJsonOrFail, + redactedArgumentError, runCliMain, throwHttpErrorIfNotOk, } from "./common.js"; @@ -115,10 +116,15 @@ function buildParseOptions(options) { // the param bivariant, so a command can declare a narrowed `values` shape (e.g. // `{ env?: string }`) and still satisfy this `Record` slot. /** + * Commands marked `sensitiveInput` redact every parser error. After parsing, + * they also reject a separate string-option value that looks like a displaced + * command word before another valid command path; explicit `--flag=value` and + * ordinary flags-before-command calls remain unambiguous. * @param {{ * name: string, * summary: string, * options?: Array, + * sensitiveInput?: { commandPaths: string[][] }, * defaults?: Record, * autoloadEnv?: boolean, * usage: () => string, @@ -127,7 +133,7 @@ function buildParseOptions(options) { * @returns {{ main: (argv?: string[]) => Promise, run: (argv?: string[], deps?: object) => Promise, meta: { name: string, summary: string, autoloadEnv: boolean, parseOptions: import("node:util").ParseArgsOptionsConfig } }} */ export function defineCommand(spec) { - const { name, summary, options = [], defaults = {}, autoloadEnv = true, usage, run } = spec; + const { name, summary, options = [], sensitiveInput, defaults = {}, autoloadEnv = true, usage, run } = spec; if (typeof name !== "string" || !name) throw new Error("defineCommand: name must be a non-empty string"); if (typeof summary !== "string" || !summary) throw new Error("defineCommand: summary must be a non-empty string"); if (typeof usage !== "function") throw new Error("defineCommand: usage must be a function"); @@ -138,22 +144,34 @@ export function defineCommand(spec) { // directly. It does NOT swallow errors — main() does that via runCliMain so // tests can still assert on the thrown CliError. async function runCommand(argv = process.argv.slice(2), deps = {}) { - const { values, positionals } = (() => { + const { values, positionals, tokens } = (() => { try { return parseArgs({ args: argv, options: parseOptions, allowPositionals: true, + tokens: true, }); } catch (err) { + if (sensitiveInput) { + throw redactedArgumentError(name); + } const message = err instanceof Error && err.message ? err.message : String(err); throw new CliError(escapeTerminalText(message)); } })(); + const helpRequested = values.help || isHelpAlias(positionals); + if ( + sensitiveInput && + !helpRequested && + hasAmbiguousSensitiveCommandPath(positionals, tokens, sensitiveInput.commandPaths) + ) { + throw redactedArgumentError(name, "put the command before its options or use --flag=value"); + } const context = buildContext(deps, defaults, values, positionals); - if (printHelpIfRequested(values.help || isHelpAlias(positionals), usage, context.stdout)) return undefined; + if (printHelpIfRequested(helpRequested, usage, context.stdout)) return undefined; return await run({ values, positionals, context }); } @@ -167,6 +185,38 @@ export function defineCommand(spec) { return { main, run: runCommand, meta: { name, summary, autoloadEnv, parseOptions } }; } +/** + * @param {string[]} positionals + * @param {Array< + * | { kind: "option", index: number, value?: string, inlineValue?: boolean } + * | { kind: "positional", index: number, value: string } + * | { kind: "option-terminator", index: number } + * >} tokens + * @param {string[][]} commandPaths + */ +function hasAmbiguousSensitiveCommandPath(positionals, tokens, commandPaths) { + let pathLength = 0; + for (const path of commandPaths) { + if (path.every((segment, index) => positionals[index] === segment)) { + pathLength = Math.max(pathLength, path.length); + } + } + if (pathLength === 0) return false; + + const positionalTokens = tokens.filter((token) => token.kind === "positional"); + const commandEndIndex = positionalTokens[pathLength - 1]?.index; + if (commandEndIndex === undefined) return false; + const commandWords = new Set(commandPaths.flat()); + return tokens.some( + (token) => + token.kind === "option" && + token.index < commandEndIndex && + token.inlineValue === false && + typeof token.value === "string" && + commandWords.has(token.value) + ); +} + // Exported for the bin dispatcher's lenient pre-scan, which must classify // help requests with the same rule the strict parse uses. /** @param {string[]} positionals */ diff --git a/lib/common.js b/lib/common.js index 1043b48..28bf1d2 100644 --- a/lib/common.js +++ b/lib/common.js @@ -24,6 +24,30 @@ export function unexpectedArgument(label, arg) { return new CliError(`${escapeTerminalText(label)} received unexpected argument: ${escapeTerminalText(arg)}`); } +/** + * Reject arguments without repeating values from a command that may receive + * credentials elsewhere in its grammar. + * @param {string} label + * @param {string} [recovery] + * @returns {CliError} + */ +export function redactedArgumentError(label, recovery = "use --help for usage") { + return new CliError( + `${escapeTerminalText(label)} received invalid arguments; argument details were redacted; ${escapeTerminalText(recovery)}` + ); +} + +/** + * Reject malformed arguments to a command that accepts sensitive input. + * @param {string} label + * @returns {CliError} + */ +export function sensitiveInputArgumentError(label) { + return new CliError( + `${escapeTerminalText(label)} received invalid arguments; provide sensitive input through the prompt or stdin` + ); +} + // The project's "set" predicate: a value is set only when it is a non-empty // string; "" or a non-string (undefined, a missing/boolean flag) counts as // absent. Centralized so the rule can't drift between its callers. @@ -308,6 +332,35 @@ export async function readJsonOrFail(res, label) { } } +/** + * Read a JSON response while allowing command-specific guidance for a control + * error code. Error JSON is parsed once and reused by the standard formatter. + * @param {import("./control-fetch.js").ControlJsonResponse} res + * @param {string} label + * @param {(error: unknown) => string} errorHint + * @returns {Promise} + */ +export async function readJsonOrFailWithHint(res, label, errorHint) { + if (res.ok) return await readJsonOrFail(res, label); + + const text = await res.text(); + const raw = text.trim(); + /** @type {unknown} */ + let body; + try { + body = JSON.parse(raw); + } catch { + throw new CliError(`${label} failed: ${formatHttpError(res.status, text, res.headers)}${errorHint(undefined)}`); + } + const error = + body && typeof body === "object" && !Array.isArray(body) + ? /** @type {{ error?: unknown }} */ (body).error + : undefined; + throw new CliError( + `${label} failed: ${formatParsedHttpError(res.status, body, raw, res.headers ?? {})}${errorHint(error)}` + ); +} + /** * @param {import("./control-fetch.js").ControlResponseStatus} res * @param {string} label diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index e8b6451..f807318 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -31,6 +31,7 @@ import { import { collectRoutes, createWranglerBundleConfig, + formatAiEnvNonInheritanceWarning, formatWranglerConfigShadowWarning, loadWranglerConfig, parseSessionPolicy, @@ -63,6 +64,7 @@ export { parseWranglerMajorVersion, resolveWranglerCommand, wranglerChildEnv } f export { collectRoutes, createWranglerBundleConfig, + formatAiEnvNonInheritanceWarning, formatWranglerConfigShadowWarning, loadWranglerConfig, parseJsonc, @@ -159,6 +161,9 @@ export async function packWranglerProject({ validateUnsupportedWranglerConfig(rawCfg, selectedEnv, configRel); return resolveWranglerConfig(rawCfg, selectedEnv, configRel); }); + const aiEnvWarning = formatAiEnvNonInheritanceWarning(rawCfg, envName, configRel); + // Verbose mode inherits Wrangler stderr and already shows its native warning. + if (aiEnvWarning && !verbose) stderr(`warning: ${aiEnvWarning}`); // Validate the type, not just truthiness: the dry-run bundle uses a sanitized // temp name, so Wrangler never checks the original cfg.name — a non-string // would otherwise be asserted as the string workerName below. diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 7027c9c..ed421f3 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -165,10 +165,32 @@ export function formatWranglerConfigShadowWarning(loaded) { } /** - * Build the config passed to Wrangler's dry-run bundler. WDL consumes these - * fields itself, so Wrangler must not interpret or validate them. - * The source config stays untouched because WDL still needs the full shape for - * its deploy manifest. + * Wrangler bindings do not inherit into named environments. The normal deploy + * path captures successful Wrangler stderr, so surface the AI-specific warning + * directly before a top-level binding can disappear from the WDL manifest. + * @param {unknown} rawCfg + * @param {string | null} envName + * @param {string} [configRel] + */ +export function formatAiEnvNonInheritanceWarning(rawCfg, envName, configRel = "wrangler config") { + if (!envName) return null; + const cfg = asRecord(rawCfg); + const envTable = asRecord(cfg?.env); + const envCfg = asRecord(envTable?.[envName]); + if (!cfg || !Object.hasOwn(cfg, "ai") || !envCfg || Object.hasOwn(envCfg, "ai")) return null; + const shownConfig = escapeTerminalText(configRel); + const shownEnv = escapeTerminalText(envName); + return ( + `${shownConfig}: top-level [ai] is not inherited into env.${shownEnv}; ` + + `declare ai inside env.${shownEnv} to bind AI in this environment` + ); +} + +/** + * Build the config passed to Wrangler's dry-run bundler. WDL-only extensions + * are removed, while standard Wrangler fields such as [ai] stay available for + * Wrangler validation. The source config stays untouched because WDL still + * needs the full shape for its deploy manifest. * @param {unknown} rawCfg * @returns {WranglerConfig} */ @@ -198,7 +220,6 @@ function stripWdlConfigExtensions(cfg) { const projected = { ...cfg }; delete projected.exports; delete projected.platform_bindings; - delete projected.ai; delete projected.wdl; const triggers = asRecord(cfg.triggers); diff --git a/templates/AGENTS.md b/templates/AGENTS.md index f1c2a78..05a32f5 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -63,6 +63,8 @@ Artifacts `triggers.events` subscriptions and R2 `local_dev.experimental_s3_credentials` are also unsupported and rejected. `[ai] binding = "AI"` declares the AI facade; provider metadata and credentials are namespace resources managed with `wdl ai`, never ordinary Worker secrets. +Like other bindings, `[ai]` is not inherited into named environments; deploy +warns when the selected environment omits a top-level AI binding. `[wdl] session_policy = "restart"` makes every promotion close the Worker's open WebSockets with `1012` and retire stale Durable Object facets on their next dispatch; the default `preserve` leaves facets on the version that built them diff --git a/tests/unit/cli-ai.test.js b/tests/unit/cli-ai.test.js index 601a1af..1f1e9dc 100644 --- a/tests/unit/cli-ai.test.js +++ b/tests/unit/cli-ai.test.js @@ -6,7 +6,7 @@ import { Readable } from "node:stream"; import { test } from "node:test"; import { runAiCommand } from "../../commands/ai.js"; -import { mockDeps, response } from "./helpers.js"; +import { ESC, assertNoRawTerminalControls, mockDeps, response } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ @@ -100,6 +100,28 @@ test("ai providers get encodes path segments and prints credential state", async ]); }); +test("ai provider human output escapes control-plane fields", async () => { + const hostile = `bad${ESC}[2J\nFORGED\rBAD\u009b`; + /** @type {string[]} */ + const lines = []; + await runAiCommand(["providers", "get", "openai", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => + response({ + provider: { + name: hostile, + revision: hostile, + kind: hostile, + models: { [hostile]: { protocol: hostile } }, + credentialConfigured: false, + }, + }), + }); + + assertNoRawTerminalControls(lines.join("\n"), "AI provider output"); +}); + test("ai providers put reads project-local JSON and reports returned credential state", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-ai-provider-")); try { @@ -244,6 +266,104 @@ test("ai credential put rejects an empty credential before mutation", async () = assert.equal(calls, 1); }); +test("ai credential errors never echo positional or option-shaped credentials", async () => { + const credential = `sk-live-${ESC}[2J\nFORGED\rBAD`; + let controlCalls = 0; + const cases = [ + ["credential", "put", "openai", credential], + ["credential", "put", "openai", `--${credential}`], + ["credential", "puts", "openai", credential], + ["credentials", "put", "openai", credential], + ["credentials", "put", "openai", `--${credential}`], + ["--control-url", "credential", "put", "openai", `--${credential}`], + ["providers", "--file", "put", "delete", credential, "--yes"], + ]; + for (const args of cases) { + await assert.rejects( + runAiCommand([...args, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => { + controlCalls += 1; + return response({ deleted: true }); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "AI credential argument error"); + assert.doesNotMatch(message, /sk-live|FORGED|BAD/); + return true; + } + ); + } + assert.equal(controlCalls, 0); + + await assert.rejects( + runAiCommand(["providers", "get", "credential", "--bogus", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /ai received invalid arguments/); + assert.match(message, /use --help for usage/); + assert.doesNotMatch(message, /prompt|stdin/); + return true; + } + ); +}); + +test("ai credential put explains missing provider setup", async () => { + await assert.rejects( + runAiCommand(["credential", "put", "openai", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => response({ error: "ai_provider_not_found", message: "AI provider not found" }, 404), + }), + /prepare AI credential failed: 404 ai_provider_not_found: AI provider not found; create the provider with `wdl ai providers put`/ + ); +}); + +test("ai credential put gives actionable mutation failure hints", async () => { + const cases = [ + { + error: "ai_provider_revision_mismatch", + status: 409, + expected: /Provider metadata changed while input was being entered; rerun this command/, + }, + { + error: "secret_encryption_unconfigured", + status: 503, + expected: /Secret-envelope configuration or stored secret data needs operator repair/, + }, + { + error: "ai_credential_encryption_unavailable", + status: 503, + expected: /Secret-envelope configuration or stored secret data needs operator repair/, + }, + ]; + + for (const fixture of cases) { + let calls = 0; + await assert.rejects( + runAiCommand(["credential", "put", "openai", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin: Readable.from(["secret-key\n"]), + controlFetch: async () => { + calls += 1; + return calls === 1 + ? response({ provider: { name: "openai", revision: "0".repeat(32), ...PROVIDER } }) + : response({ error: fixture.error, message: "mutation failed" }, fixture.status); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, fixture.expected); + assert.doesNotMatch(message, /secret-key/); + return true; + } + ); + assert.equal(calls, 2); + } +}); + test("ai providers delete confirms and deletes metadata with its credential", async () => { const { calls, deps } = mockDeps({ ok: true, deleted: true }); /** @type {string[]} */ @@ -257,6 +377,22 @@ test("ai providers delete confirms and deletes metadata with its credential", as assert.deepEqual(lines, ["OK AI provider openai and its credential deleted"]); }); +test("ai providers delete refuses a non-interactive deletion without --yes", async () => { + let calls = 0; + await assert.rejects( + runAiCommand(["providers", "delete", "openai", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin: /** @type {import("../../lib/stdin.js").StdinLike} */ (/** @type {unknown} */ ({ isTTY: false })), + controlFetch: async () => { + calls += 1; + return response({}); + }, + }), + /Refusing to delete AI provider "demo\/openai" without interactive confirmation/ + ); + assert.equal(calls, 0); +}); + test("ai rejects incomplete and unknown commands", async () => { await assert.rejects( runAiCommand(["providers", "put", "--ns", "demo", "--control-url", "http://ctl.test"], { @@ -265,8 +401,8 @@ test("ai rejects incomplete and unknown commands", async () => { /requires / ); await assert.rejects( - runAiCommand(["unknown", "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, + runAiCommand(["unknown"], { + env: { ADMIN_TOKEN: "tok", CONTROL_URL: "http://ctl.test", WDL_NS: "demo" }, }), /unknown ai command/ ); diff --git a/tests/unit/cli-command.test.js b/tests/unit/cli-command.test.js index d52d218..69dd81e 100644 --- a/tests/unit/cli-command.test.js +++ b/tests/unit/cli-command.test.js @@ -64,6 +64,61 @@ test("defineCommand direct runner escapes parseArgs errors", async () => { ); }); +test("defineCommand redacts all parse errors for sensitive commands", async () => { + const secretOption = `--sk-live-${ESC}[2J\nFORGED\rBAD`; + const cmd = define({ + options: ["ns", "help"], + sensitiveInput: { + commandPaths: [ + ["credential", "put"], + ["providers", "get"], + ], + }, + usage: () => "usage", + run: () => { + throw new Error("run body should not be called"); + }, + }); + + await assert.rejects( + () => cmd.run(["credential", "put", "openai", secretOption, "--ns", "demo"]), + (err) => { + assert(err instanceof CliError); + assertNoRawTerminalControls(err.message, "sensitive parse errors"); + assert.match(err.message, /t received invalid arguments/); + assert.match(err.message, /argument details were redacted; use --help for usage/); + assert.doesNotMatch(err.message, /prompt|stdin/); + assert.doesNotMatch(err.message, /sk-live|FORGED|BAD/); + return true; + } + ); + + await assert.rejects( + () => cmd.run(["providers", "get", "credential", "--bogus"]), + (err) => { + assert(err instanceof CliError); + assert.equal(err.message, "t received invalid arguments; argument details were redacted; use --help for usage"); + return true; + } + ); + + await assert.rejects( + () => cmd.run(["--ns", "credential", "providers", "get"]), + /put the command before its options or use --flag=value/ + ); + await assert.rejects( + () => cmd.run(["providers", "--ns", "get", "get", "credential"]), + /t received invalid arguments; argument details were redacted/ + ); + + /** @type {string[]} */ + const lines = []; + await cmd.run(["--ns", "credential", "providers", "get", "--help"], { + stdout: (/** @type {string} */ line) => lines.push(line), + }); + assert.deepEqual(lines, ["usage"]); +}); + test("defineCommand exposes autoloadEnv metadata", () => { const cmd = defineCommand({ name: "doctor", diff --git a/tests/unit/cli-common.test.js b/tests/unit/cli-common.test.js index b475645..3240237 100644 --- a/tests/unit/cli-common.test.js +++ b/tests/unit/cli-common.test.js @@ -5,7 +5,7 @@ import { runR2Command } from "../../commands/r2.js"; import { runSecretCommand } from "../../commands/secret.js"; import { runWorkersCommand } from "../../commands/workers.js"; import { runWorkflowsCommand } from "../../commands/workflows.js"; -import { formatHttpError, formatHttpErrorBody, readJsonOrFail } from "../../lib/common.js"; +import { formatHttpError, formatHttpErrorBody, readJsonOrFail, readJsonOrFailWithHint } from "../../lib/common.js"; import { ESC, assertNoRawTerminalControls, response } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ @@ -27,6 +27,27 @@ test("formatHttpErrorBody matches raw JSON formatting for parsed bodies", () => assert.equal(formatHttpErrorBody(409, body), formatHttpError(409, JSON.stringify(body))); }); +test("readJsonOrFailWithHint formats one error-body read and appends code-specific guidance", async () => { + let textReads = 0; + await assert.rejects( + () => + readJsonOrFailWithHint( + { + status: 409, + ok: false, + text: async () => { + textReads += 1; + return '{ "error": "retry", "message": "changed" }'; + }, + }, + "mutate", + (error) => (error === "retry" ? "; rerun the command" : "") + ), + { message: "mutate failed: 409 retry: changed; rerun the command" } + ); + assert.equal(textReads, 1); +}); + test("readJsonOrFail compacts redacted D1 lifecycle errors", async () => { const errBody = { error: "d1_database_initialize_failed", @@ -339,10 +360,6 @@ test("commands escape terminal controls in unexpected positional errors", async () => runDeleteCommand(["version", "--ns", "demo", "api", "v1", bad], deps), assertEscapedBadArg ); - await assert.rejects( - () => runSecretCommand(["list", "--ns", "demo", "--scope", "ns", bad], deps), - assertEscapedBadArg - ); await assert.rejects(() => runR2Command(["buckets", "list", bad, "--ns", "demo"], deps), assertEscapedBadArg); await assert.rejects(() => runWorkflowsCommand(["list", "--ns", "demo", bad], deps), assertEscapedBadArg); }); diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index beaaf4a..29f5b93 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -382,7 +382,7 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w assert.equal(tmpConfigContentAtExec.main, "src/index.js"); assert.deepEqual(tmpConfigContentAtExec.vars, { GREETING: "hi" }); assert.equal(tmpConfigContentAtExec.exports, undefined); - assert.equal(tmpConfigContentAtExec.ai, undefined); + assert.deepEqual(tmpConfigContentAtExec.ai, { binding: "AI" }); assert.ok(tmpConfigSeen); assert.match(path.basename(tmpConfigSeen), /^\.wrangler\.wdl-tmp-[a-f0-9-]+\.json$/); assert.notEqual(tmpConfigSeen, path.join(dir, ".wrangler.wdl-tmp.json")); @@ -394,6 +394,35 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w } }); +test("runDeployCommand warns when a selected environment does not inherit top-level AI", async (t) => { + const dir = createDeployProject( + t, + ['name = "api"', 'main = "src/index.js"', "[ai]", 'binding = "AI"', "[env.prod]"].join("\n"), + "wdl-run-deploy-ai-env-warning-" + ); + const { calls, controlFetch } = deployPromoteFetch( + { version: "v1", warnings: [] }, + { platformDomain: "workers.example" } + ); + /** @type {string[]} */ + const warnings = []; + + await runDeployCommand([dir, "--env", "prod", "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: (/** @type {string} */ line) => warnings.push(line), + execFile: fakeWranglerExecFile, + controlFetch, + }); + + assert.deepEqual(warnings, [ + "warning: wrangler.toml: top-level [ai] is not inherited into env.prod; " + + "declare ai inside env.prod to bind AI in this environment", + ]); + const manifest = JSON.parse(/** @type {string} */ (calls[0].init.body)); + assert.equal(manifest.bindings, undefined); +}); + test("runDeployCommand removes the sanitized temp config when wrangler exec fails", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-mixedcase-fail-")); try { diff --git a/tests/unit/cli-secret.test.js b/tests/unit/cli-secret.test.js index dd9772b..dc438ad 100644 --- a/tests/unit/cli-secret.test.js +++ b/tests/unit/cli-secret.test.js @@ -2,16 +2,39 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; import { runSecretCommand } from "../../commands/secret.js"; -import { mockDeps, response, stdinFrom, ttyStdinLine } from "./helpers.js"; +import { ESC, assertNoRawTerminalControls, mockDeps, response, stdinFrom, ttyStdinLine } from "./helpers.js"; /** @typedef {import("./helpers.js").ControlCall} ControlCall */ -test("secret list accepts flags before the subcommand", async () => { +test("secret list accepts flags before the subcommand and inline command-word values", async () => { const { calls, deps } = mockDeps({ keys: [] }); await runSecretCommand(["--ns", "demo", "--worker", "api", "--control-url", "http://ctl.test", "list"], deps); + await runSecretCommand(["--ns", "demo", "--worker=put", "--control-url", "http://ctl.test", "list"], deps); + await runSecretCommand(["--ns=list", "--scope", "ns", "--control-url", "http://ctl.test", "list"], deps); assert.equal(calls[0].url, "http://ctl.test/ns/demo/worker/api/secrets"); + assert.equal(calls[1].url, "http://ctl.test/ns/demo/worker/put/secrets"); + assert.equal(calls[2].url, "http://ctl.test/ns/list/secrets"); +}); + +test("secret rejects separated command-word option values before the subcommand", async () => { + let calls = 0; + const deps = { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => { + calls += 1; + return response({ keys: [] }); + }, + }; + + for (const args of [ + ["--ns", "demo", "--worker", "put", "--control-url", "http://ctl.test", "list"], + ["--ns", "list", "--scope", "ns", "--control-url", "http://ctl.test", "list"], + ]) { + await assert.rejects(() => runSecretCommand(args, deps), /put the command before its options or use --flag=value/); + } + assert.equal(calls, 0); }); test("secret list uses encoded namespace and worker path segments", async () => { @@ -291,21 +314,29 @@ test("secret list refuses ambiguous scope before calling control", async () => { assert.equal(calls.length, 0); }); -test("secret list and delete reject unexpected positional arguments", async () => { +test("secret list and delete reject unexpected positional arguments without echoing them", async () => { const deps = { env: { ADMIN_TOKEN: "tok" }, controlFetch: async () => { throw new Error("controlFetch should not be called"); }, }; - await assert.rejects( - () => runSecretCommand(["list", "--ns", "demo", "--scope", "ns", "extra"], deps), - /secret list received unexpected argument: extra/ - ); - await assert.rejects( - () => runSecretCommand(["delete", "--ns", "demo", "--scope", "ns", "KEY", "extra", "--yes"], deps), - /secret delete received unexpected argument: extra/ - ); + for (const args of [ + ["list", "--ns", "demo", "--scope", "ns", "extra"], + ["delete", "--ns", "demo", "--scope", "ns", "KEY", "extra", "--yes"], + ]) { + await assert.rejects( + () => runSecretCommand(args, deps), + (err) => { + const message = /** @type {Error} */ (err).message; + assert.match(message, /received invalid arguments/); + assert.match(message, /use --help for usage/); + assert.doesNotMatch(message, /prompt|stdin/); + assert.doesNotMatch(message, /extra/); + return true; + } + ); + } }); test("secret delete calls worker endpoint and reports promoted bump", async () => { @@ -401,22 +432,65 @@ test("secret delete ignores obsolete deferred-promote warnings", async () => { assert.deepEqual(lines, ["(KEY was not set)"]); }); -test("secret put rejects an unexpected VALUE positional before reading stdin", async () => { +test("secret put rejects sensitive positional and option arguments without echoing them", async () => { let read = false; - await assert.rejects( - () => - runSecretCommand(["put", "--ns", "demo", "--scope", "ns", "KEY", "VALUE", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdin: Object.assign(new EventEmitter(), { - setEncoding() { - read = true; + let controlCalls = 0; + const secret = `sk-live-${ESC}[2J\nFORGED\rBAD`; + for (const value of [secret, `--${secret}`]) { + await assert.rejects( + () => + runSecretCommand(["put", "--ns", "demo", "--scope", "ns", "KEY", value, "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdin: Object.assign(new EventEmitter(), { + setEncoding() { + read = true; + }, + }), + controlFetch: async () => { + throw new Error("controlFetch should not be called"); }, }), - controlFetch: async () => { - throw new Error("controlFetch should not be called"); - }, - }), - /secret put received unexpected argument: VALUE/ + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "secret put argument error"); + assert.doesNotMatch(message, /sk-live|FORGED|BAD/); + return true; + } + ); + } + for (const args of [ + ["puts", "KEY", `--${secret}`], + ["--worker", "put", "KEY", `--${secret}`], + ["--worker", "put", "list", secret], + ["--worker", "put", "list", `--${secret}`], + ["--worker", "put", "delete", secret], + ["--worker", "put", "delete", secret, "--yes"], + ]) { + await assert.rejects( + () => + runSecretCommand([...args, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + controlFetch: async () => { + controlCalls += 1; + return response({ deleted: true }); + }, + }), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "ambiguous secret argument error"); + assert.doesNotMatch(message, /sk-live|FORGED|BAD/); + return true; + } + ); + } + await assert.rejects( + () => + runSecretCommand( + ["delete", "put", "--bogus", "--scope", "ns", "--yes", "--ns", "demo", "--control-url", "http://ctl.test"], + { env: { ADMIN_TOKEN: "tok" } } + ), + /secret received invalid arguments/ ); assert.equal(read, false); + assert.equal(controlCalls, 0); }); diff --git a/tests/unit/cli-token.test.js b/tests/unit/cli-token.test.js index cfb423c..9d977d3 100644 --- a/tests/unit/cli-token.test.js +++ b/tests/unit/cli-token.test.js @@ -341,15 +341,36 @@ test("writeTokenStore replaces a symlink instead of following it", POSIX_ONLY, a }); }); -test("token does not accept a --token flag (the token comes from stdin)", async () => { - await withTempXdg(async (xdg) => { +test("token parse errors never echo option-shaped tokens", async () => { + await withTempXdg(async (xdg) => { + const token = `sk-live-${ESC}[2J\nFORGED\rBAD`; + for (const args of [ + ["set", "--ns", "acme", "--control-url", "https://api.example", "--token", token], + ["set", "--ns", "acme", "--control-url", "https://api.example", `--${token}`], + ["--control-url", "set", "--ns", "acme", `--${token}`], + ]) { + await assert.rejects( + () => runTokenCommand(args, deps(xdg, { stdin: stdinFrom("tok\n") }).deps), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "token argument error"); + assert.match(message, /token received invalid arguments/); + assert.match(message, /use --help for usage/); + assert.doesNotMatch(message, /prompt|stdin/); + assert.doesNotMatch(message, /sk-live|FORGED|BAD/); + return true; + } + ); + } await assert.rejects( - () => - runTokenCommand( - ["set", "--ns", "acme", "--control-url", "https://api.example", "--token", "x"], - deps(xdg, { stdin: stdinFrom("tok\n") }).deps - ), - /Unknown option|--token/ + () => runTokenCommand(["--ns", "set", "use", token], deps(xdg, { stdin: stdinFrom("tok\n") }).deps), + (err) => { + const message = /** @type {Error} */ (err).message; + assertNoRawTerminalControls(message, "ambiguous token argument error"); + assert.match(message, /put the command before its options or use --flag=value/); + assert.doesNotMatch(message, /sk-live|FORGED|BAD/); + return true; + } ); }); }); diff --git a/tests/unit/cli-wrangler-config.test.js b/tests/unit/cli-wrangler-config.test.js index e68d154..72c12fd 100644 --- a/tests/unit/cli-wrangler-config.test.js +++ b/tests/unit/cli-wrangler-config.test.js @@ -447,7 +447,7 @@ test("resolveWranglerConfig drops __proto__ keys instead of rewriting the merged assert.deepEqual(cfg.vars, { A: "1" }); }); -test("createWranglerBundleConfig projects WDL extensions without mutating source config", () => { +test("createWranglerBundleConfig keeps standard fields while projecting WDL extensions", () => { const rawCfg = { name: "demo", main: "src/index.js", @@ -469,6 +469,7 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source ], exports: [{ entrypoint: "Auth", allowed_callers: ["acme"] }], platform_bindings: [{ binding: "PAYMENT", platform: "STRIPE" }], + ai: { binding: "AI" }, wdl: { session_policy: "restart" }, env: { staging: { @@ -480,6 +481,7 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source services: [{ binding: "API", service: "api-worker", ns: "backend", remote: false }], exports: [{ entrypoint: "default", allowed_callers: ["*"] }], platform_bindings: [{ binding: "SEARCH", platform: "SEARCH" }], + ai: { binding: "AI_STAGING" }, wdl: { session_policy: "preserve" }, }, }, @@ -492,6 +494,7 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source assert.equal(projected.name, "wdl-bundle-tmp"); assert.equal(projected.exports, undefined); assert.equal(projected.platform_bindings, undefined); + assert.deepEqual(projected.ai, { binding: "AI" }); assert.equal(projected.wdl, undefined); assert.deepEqual(projected.build, { command: "npm run build" }); assert.deepEqual(projected.vars, { MODE: "top" }); @@ -511,6 +514,7 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source assert.deepEqual(projectedEnv.staging.services, [{ binding: "API", service: "api-worker", remote: false }]); assert.equal(projectedEnv.staging.exports, undefined); assert.equal(projectedEnv.staging.platform_bindings, undefined); + assert.deepEqual(projectedEnv.staging.ai, { binding: "AI_STAGING" }); assert.equal(projectedEnv.staging.wdl, undefined); }); From e0d317430f490f8d51d17c1c51a81af40fd879e7 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 17 Aug 2026 12:04:38 +0800 Subject: [PATCH 4/5] Add AI provider configuration initializer Generate conservative one-model provider JSON with editable defaults and safe project-local output. Keep initialization offline while reusing shared TTY prompting and sensitive argument handling. Document the workflow and cover defaults, containment, credential autoload, and prompt behavior. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 7 + CHANGELOG.md | 7 +- GUIDE-zh.md | 3 + GUIDE.md | 9 ++ README-zh.md | 2 +- README.md | 2 +- bin/wdl.js | 9 +- commands/ai.js | 108 ++++++++++++- docs/ai-zh.md | 29 ++-- docs/ai.md | 42 +++-- examples/ai-agent-demo/provider.openai.json | 2 +- lib/ai-provider-init.js | 122 ++++++++++++++ lib/command.js | 4 +- lib/stdin.js | 90 ++++++++++- templates/AGENTS.md | 2 + tests/unit/cli-ai.test.js | 171 +++++++++++++++++++- tests/unit/cli-dispatcher.test.js | 29 ++++ tests/unit/cli-stdin.test.js | 20 ++- 18 files changed, 621 insertions(+), 37 deletions(-) create mode 100644 lib/ai-provider-init.js diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index b898732..a020c2b 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -113,6 +113,13 @@ also a command word, put the subcommand first or use `--flag=value`; for example, use `wdl secret list --worker put` or `wdl secret --worker=put list` for a worker named `put`. +Use `wdl ai providers init ` to scaffold a one-model provider file +without loading credentials or contacting Control. It offers editable defaults +for kind, the `primary` alias, model id, and output filename. It pre-fills +`gpt-5.6-luna`, `grok-4.6`, or `deepseek-v4-flash` for the matching adapter and +emits a conservative text-only Responses descriptor over HTTP/SSE. It refuses to +overwrite files; edit the JSON for model-specific protocols or capabilities. + `wdl ai providers put` replaces the complete provider record and accepts only a project-contained `{ kind, models }` file; omitted model aliases are removed. When editing an existing provider, derive the file with diff --git a/CHANGELOG.md b/CHANGELOG.md index d558e66..7cd63b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,10 @@ ### Added -- Add the `[ai]` binding, namespace provider and credential management commands, - model discovery, OpenAI-compatible SDK guidance, and a Responses function-tool - agent example protected by a Worker-level bearer token. +- Add the `[ai]` binding, an offline provider JSON initializer, namespace + provider and credential management commands, model discovery, + OpenAI-compatible SDK guidance, and a Responses function-tool agent example + protected by a Worker-level bearer token. ### Changed diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 29cc81b..25e4152 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -374,11 +374,14 @@ binding = "AI" ``` ```bash +wdl ai providers init openai wdl ai providers put openai --file provider.openai.json printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai wdl ai models ``` +`providers init` 是生成单模型 provider 文件的离线交互式脚手架。它会为 provider kind、`primary` alias、upstream model id 和 `provider..json` 文件名提供可修改的默认值并拒绝覆盖已有文件。它会为对应 adapter 预填 `gpt-5.6-luna`、`grok-4.6` 或 `deepseek-v4-flash`,并只生成 text-only Responses + HTTP/SSE 的保守 descriptor;其他 model-specific protocol 或 capability 需要直接编辑 JSON。Control 仍然是 canonical validator。 + Provider 元数据选择官方 adapter(`openai`、`xai` 或 `deepseek`),并把 `openai/primary` 这类有界 alias 映射到原生 model id 和 capability。更新元数据会生成新 revision;同 kind 更新保留既有 credential,切换 adapter kind 才会清除 credential 并要求在推理前重新配置。凭据是加密的 namespace 资源,不会进入 Worker env 或 bundle metadata。 `providers put` 会整条替换 `{ kind, models }` metadata,省略的 alias 会被删除。输入文件必须留在当前项目目录内;编辑 `providers get --json` 的响应前,先提取可写字段: diff --git a/GUIDE.md b/GUIDE.md index b68e901..e19a75a 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -571,11 +571,20 @@ binding = "AI" ``` ```bash +wdl ai providers init openai wdl ai providers put openai --file provider.openai.json printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai wdl ai models ``` +`providers init` is an offline interactive scaffold for a single-model provider +file. It offers editable defaults for the provider kind, `primary` alias, +upstream model id, and `provider..json` filename, and refuses to +overwrite. It pre-fills `gpt-5.6-luna`, `grok-4.6`, or `deepseek-v4-flash` for +the matching adapter and emits a conservative text-only Responses descriptor +over HTTP/SSE. Edit the JSON for model-specific protocols or capabilities; +Control remains the canonical validator. + Provider metadata selects one official adapter (`openai`, `xai`, or `deepseek`) and maps bounded aliases such as `openai/primary` to native model ids and capabilities. Updating metadata creates a new revision. Same-kind updates diff --git a/README-zh.md b/README-zh.md index f9e4d8f..11920d2 100644 --- a/README-zh.md +++ b/README-zh.md @@ -78,7 +78,7 @@ wdl token list [--json] / wdl token use / wdl token rm --ns wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... -wdl ai providers ... / wdl ai credential put ... / wdl ai models +wdl ai providers ... / wdl ai credential put ... / wdl ai models wdl delete worker [--dry-run] / wdl delete version wdl config explain / wdl doctor / wdl whoami [--json] wdl --version / wdl --help / wdl help diff --git a/README.md b/README.md index d999a0a..fc2cec7 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ wdl token list [--json] / wdl token use / wdl token rm --ns wdl d1 ... wdl r2 buckets list / wdl r2 objects ... wdl workflows ... -wdl ai providers ... / wdl ai credential put ... / wdl ai models +wdl ai providers ... / wdl ai credential put ... / wdl ai models wdl delete worker [--dry-run] / wdl delete version wdl config explain / wdl doctor / wdl whoami [--json] wdl --version / wdl --help / wdl help diff --git a/bin/wdl.js b/bin/wdl.js index 51ec879..f71f7e8 100755 --- a/bin/wdl.js +++ b/bin/wdl.js @@ -62,7 +62,7 @@ for (const c of REGISTRY) { * and the metadata the dispatcher reads. * @typedef {{ * main: (argv?: string[]) => Promise, - * meta: { name: string, summary: string, autoloadEnv: boolean, parseOptions: import("node:util").ParseArgsOptionsConfig }, + * meta: { name: string, summary: string, autoloadEnv: boolean | ((positionals: string[]) => boolean), parseOptions: import("node:util").ParseArgsOptionsConfig }, * }} CommandModule */ @@ -107,8 +107,12 @@ export async function main(argv = process.argv.slice(2), deps = {}) { /** @type {NonNullable[1]>["loadEnv"]} */ const loadEnvOverride = (Object.hasOwn(deps, "loadEnv") ? deps.loadEnv : undefined) ?? undefined; const skipAutoload = Object.hasOwn(deps, "loadEnv") && !deps.loadEnv; + const autoloadEnv = + typeof commandModule.meta.autoloadEnv === "function" + ? commandModule.meta.autoloadEnv(scanned.positionals) + : commandModule.meta.autoloadEnv; // Help never needs credentials, so a malformed .env must not block it. - if (!skipAutoload && commandModule.meta.autoloadEnv && !scanned.help) { + if (!skipAutoload && autoloadEnv && !scanned.help) { try { loadCliControlEnv(env, { nsFromFlag: scanned.ns, @@ -154,6 +158,7 @@ function scanCommandArgs(commandModule, args) { controlUrlFromFlag: flagSet(values, "control-url"), noTokenStore: values["no-token-store"] === true, help: values.help === true || isHelpAlias(positionals), + positionals, }; } diff --git a/commands/ai.js b/commands/ai.js index 4f8b09d..151ecb2 100644 --- a/commands/ai.js +++ b/commands/ai.js @@ -1,6 +1,13 @@ import { existsSync, readFileSync, realpathSync } from "node:fs"; import path from "node:path"; +import { + AI_PROVIDER_KINDS, + createAiProviderConfig, + defaultAiProviderModel, + defaultAiProviderFile, + writeAiProviderFile, +} from "../lib/ai-provider-init.js"; import { defineCommand } from "../lib/command.js"; import { CliError, @@ -13,12 +20,15 @@ import { redactedArgumentError, sensitiveInputArgumentError, } from "../lib/common.js"; -import { escapeTerminalText, writeJsonOr, writeStatusLine } from "../lib/output.js"; +import { escapeTerminalText, shellSingleQuote, writeJsonOr, writeStatusLine } from "../lib/output.js"; import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; -import { confirmAction, readSecretStdin } from "../lib/stdin.js"; +import { confirmAction, readSecretStdin, readTtyLines } from "../lib/stdin.js"; const AI_OPTIONS = [ - defineCliOption("file", { type: "string" }, "--file ", "Provider JSON file for providers put."), + defineCliOption("file", { type: "string" }, "--file ", "Provider JSON input or init output file."), + defineCliOption("kind", { type: "string" }, "--kind ", "Override the inferred provider kind for init."), + defineCliOption("model", { type: "string" }, "--model ", "Override the default upstream model id."), + defineCliOption("alias", { type: "string" }, "--alias ", "Model alias for providers init (default: primary)."), defineCliOption("yes", { type: "boolean" }, "--yes", "Skip provider delete confirmation."), "ns", "control", @@ -37,9 +47,12 @@ const command = defineCommand({ ["providers", "get"], ["providers", "put"], ["providers", "delete"], + ["providers", "init"], ["credential", "put"], ], }, + defaults: { readLines: readTtyLines }, + autoloadEnv: (positionals) => !isProviderInitCommand(positionals), usage: usageText, run: runAi, }); @@ -51,15 +64,26 @@ export const meta = command.meta; /** * @typedef {import("../lib/command.js").PresetFlags<"ns" | "control" | "json"> & { * file?: string, + * kind?: string, + * model?: string, + * alias?: string, * yes?: boolean, * }} AiFlags */ -/** @param {{ values: AiFlags, positionals: string[], context: import("../lib/command.js").CommandContext }} arg */ +/** @param {{ values: AiFlags, positionals: string[], context: import("../lib/command.js").CommandContext & { readLines: typeof readTtyLines } }} arg */ async function runAi({ values, positionals, context }) { const { stdout, stderr, stdin } = context; const [group, action, provider] = positionals; const extraArg = positionals[3]; + + if (group === "providers" && action === "init") { + requireProvider(provider, "ai providers init"); + if (extraArg) throw redactedArgumentError("ai providers init"); + await initProviderFile(values, provider, context); + return; + } + const ns = context.resolveNamespace(); if (!group || !ns) throw new CliError(usageText()); @@ -230,6 +254,81 @@ function requireProvider(provider, commandName) { if (!provider) throw new CliError(`${commandName} requires `); } +/** + * @param {AiFlags} values + * @param {string} provider + * @param {import("../lib/command.js").CommandContext & { readLines: typeof readTtyLines }} context + */ +async function initProviderFile(values, provider, context) { + const { kind, alias, upstreamModel, file } = await collectInitValues(values, provider, context); + + const body = createAiProviderConfig({ provider, kind, alias, upstreamModel }); + const writtenFile = writeAiProviderFile(context.cwd, file, body); + + writeStatusLine(context.stdout, `Created ${writtenFile}.`); + writeStatusLine(context.stdout, "Review the generated modalities and capabilities before uploading it."); + writeStatusLine( + context.stdout, + `Next: wdl ai providers put ${shellSingleQuote(provider)} --file ${shellSingleQuote(writtenFile)} --ns ` + ); +} + +/** + * @param {AiFlags} values + * @param {string} provider + * @param {import("../lib/command.js").CommandContext & { readLines: typeof readTtyLines }} context + */ +async function collectInitValues(values, provider, context) { + const provided = { + kind: typeof values.kind === "string" ? values.kind.trim() : "", + alias: typeof values.alias === "string" ? values.alias.trim() : "", + upstreamModel: typeof values.model === "string" ? values.model.trim() : "", + file: typeof values.file === "string" ? values.file.trim() : "", + }; + const defaultKind = AI_PROVIDER_KINDS.includes(provider) ? provider : "openai"; + const defaultFile = defaultAiProviderFile(provider); + /** @type {Record} */ + const entered = {}; + if (context.stdin.isTTY) { + /** @param {readonly string[]} answers */ + const modelPrompt = (answers) => { + const kind = provided.kind || answers[0]?.trim() || defaultKind; + const model = defaultAiProviderModel(kind); + return model ? `Upstream model id [${model}]: ` : "Upstream model id: "; + }; + const fields = [ + { + key: "kind", + value: provided.kind, + prompt: `Provider kind (${AI_PROVIDER_KINDS.join("/")}) [${defaultKind}]: `, + }, + { key: "alias", value: provided.alias, prompt: "Model alias [primary]: " }, + { key: "upstreamModel", value: provided.upstreamModel, prompt: modelPrompt }, + { key: "file", value: provided.file, prompt: `Output file [${defaultFile}]: ` }, + ]; + const missing = fields.filter((field) => !field.value); + const answers = await context.readLines(context.stdin, { + prompts: missing.map((field) => field.prompt), + stderr: context.stderr, + }); + for (const [index, field] of missing.entries()) { + entered[field.key] = answers[index]?.trim() ?? ""; + } + } + const kind = provided.kind || entered.kind || defaultKind; + return { + kind, + alias: provided.alias || entered.alias || "primary", + upstreamModel: provided.upstreamModel || entered.upstreamModel || defaultAiProviderModel(kind) || "", + file: provided.file || entered.file || defaultFile, + }; +} + +/** @param {string[]} positionals */ +function isProviderInitCommand(positionals) { + return positionals[0] === "providers" && positionals[1] === "init"; +} + /** @param {string | undefined} file @param {string} cwd */ function readProviderFile(file, cwd) { if (typeof file !== "string" || !file) throw new CliError("ai providers put requires --file "); @@ -298,6 +397,7 @@ function usageText() { usage: [ "wdl ai providers list [options]", "wdl ai providers get [options] ", + "wdl ai providers init [options] ", "wdl ai providers put [options] --file ", "wdl ai providers delete [options] [--yes]", "wdl ai credential put [options] ", diff --git a/docs/ai-zh.md b/docs/ai-zh.md index 87e7c2e..601fe47 100644 --- a/docs/ai-zh.md +++ b/docs/ai-zh.md @@ -25,24 +25,34 @@ binding = "AI" Provider 元数据和凭据是命名空间资源。即使命名空间暂时没有任何已部署 Worker,它们仍会保留,与 namespace secret 的生命周期一致。 -在项目内创建 provider JSON 文件: +常见的单模型配置可以通过交互方式生成项目内的 provider JSON 文件: + +```bash +wdl ai providers init openai +``` + +这个本地 initializer 会为 provider kind、model alias、upstream model id 和输出文件名提供默认值,并允许交互用户逐项修改;已有文件不会被覆盖。非交互 shell 执行同一条命令时也会使用这些默认值。 + +名称为 `openai`、`xai` 或 `deepseek` 的 provider 会选择同名 kind,其他名称默认使用 `openai`;可以用 `--kind` 覆盖。`--alias` 和 `--file` 可覆盖默认的 `primary` alias 与文件名。Initializer 会为 OpenAI、xAI 和 DeepSeek 分别预填 `gpt-5.6-luna`、`grok-4.6` 和 `deepseek-v4-flash`,也可以用 `--model` 覆盖这些起始值。它只生成 text-only Responses + HTTP/SSE 的保守 descriptor,并关闭全部可选 capability;其他 protocol、transport、modality 或 capability 需要直接编辑 JSON。Initializer 完全离线,不读取 WDL credential,也不访问 Control;Control 仍然是 canonical validator。 + +生成文件与手写文件使用相同的可写形状: ```json { "kind": "openai", "models": { "primary": { - "upstreamModel": "gpt-5", + "upstreamModel": "gpt-5.6-luna", "protocol": "responses", - "transports": ["http", "sse", "responses_websocket"], - "inputModalities": ["image", "text"], + "transports": ["http", "sse"], + "inputModalities": ["text"], "outputModalities": ["text"], "capabilities": { - "functionTools": true, - "structuredOutput": true, - "reasoning": true, - "previousResponseId": true, - "providerTools": true, + "functionTools": false, + "structuredOutput": false, + "reasoning": false, + "previousResponseId": false, + "providerTools": false, "binaryFrames": false } } @@ -83,6 +93,7 @@ Provider `name` 与模型 alias 组成租户模型 id `/`,例 Provider 管理命令: ```bash +wdl ai providers init [options] wdl ai providers list [--json] wdl ai providers get [--json] wdl ai providers put --file [--json] diff --git a/docs/ai.md b/docs/ai.md index 36eb8f6..decd8d1 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -40,24 +40,47 @@ provides the facade. Provider metadata and credentials are namespace resources. They remain after the namespace has zero deployed Workers, matching namespace-secret lifecycle. -Create a project-local provider JSON file: +For a common single-model configuration, generate a project-local provider JSON +file interactively: + +```bash +wdl ai providers init openai +``` + +The local initializer offers defaults for the provider kind, model alias, +upstream model id, and output filename, and lets an interactive user change each +one. It refuses to overwrite an existing file. The same command works +non-interactively with those defaults. + +Provider names matching `openai`, `xai`, or `deepseek` select that kind; other +names default to `openai`. Use `--kind`, `--alias`, and `--file` to override the +inferred kind, `primary` alias, and default filename. The initializer pre-fills +`gpt-5.6-luna` for OpenAI, `grok-4.6` for xAI, and `deepseek-v4-flash` for +DeepSeek; use `--model` to override these starting values. It emits a +conservative text-only Responses descriptor over HTTP/SSE with all optional +capabilities disabled. Edit the JSON when the selected model needs another +protocol, transport, modality, or capability. The initializer is offline and +does not read WDL credentials or contact Control; Control remains the canonical +validator. + +The generated file has the same writable shape as a manually authored file: ```json { "kind": "openai", "models": { "primary": { - "upstreamModel": "gpt-5", + "upstreamModel": "gpt-5.6-luna", "protocol": "responses", - "transports": ["http", "sse", "responses_websocket"], - "inputModalities": ["image", "text"], + "transports": ["http", "sse"], + "inputModalities": ["text"], "outputModalities": ["text"], "capabilities": { - "functionTools": true, - "structuredOutput": true, - "reasoning": true, - "previousResponseId": true, - "providerTools": true, + "functionTools": false, + "structuredOutput": false, + "reasoning": false, + "previousResponseId": false, + "providerTools": false, "binaryFrames": false } } @@ -110,6 +133,7 @@ provider's native model id and may use provider-specific punctuation. Provider management commands: ```bash +wdl ai providers init [options] wdl ai providers list [--json] wdl ai providers get [--json] wdl ai providers put --file [--json] diff --git a/examples/ai-agent-demo/provider.openai.json b/examples/ai-agent-demo/provider.openai.json index 5e6b56e..ecea650 100644 --- a/examples/ai-agent-demo/provider.openai.json +++ b/examples/ai-agent-demo/provider.openai.json @@ -2,7 +2,7 @@ "kind": "openai", "models": { "primary": { - "upstreamModel": "gpt-5", + "upstreamModel": "gpt-5.6-luna", "protocol": "responses", "transports": ["http", "sse", "responses_websocket"], "inputModalities": ["image", "text"], diff --git a/lib/ai-provider-init.js b/lib/ai-provider-init.js new file mode 100644 index 0000000..f704b0f --- /dev/null +++ b/lib/ai-provider-init.js @@ -0,0 +1,122 @@ +import { existsSync, realpathSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import { CliError, isPathInside } from "./common.js"; +import { escapeTerminalText } from "./output.js"; + +export const AI_PROVIDER_KINDS = Object.freeze(["openai", "xai", "deepseek"]); + +/** @param {string | undefined} kind */ +export function defaultAiProviderModel(kind) { + switch (kind) { + case "openai": + return "gpt-5.6-luna"; + case "xai": + return "grok-4.6"; + case "deepseek": + return "deepseek-v4-flash"; + default: + return undefined; + } +} + +const PROVIDER_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$/; +const MODEL_ALIAS_RE = /^(?![0-9]+$)[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/; + +/** @param {string} provider */ +export function defaultAiProviderFile(provider) { + validateProviderName(provider); + return `provider.${provider}.json`; +} + +/** + * Create a conservative, broadly supported Responses descriptor. Users can + * edit the generated JSON for model-specific protocols and capabilities; + * Control remains the canonical validator. + * @param {{ provider: string, kind: string, alias: string, upstreamModel: string }} input + */ +export function createAiProviderConfig({ provider, kind, alias, upstreamModel }) { + validateProviderName(provider); + if (!AI_PROVIDER_KINDS.includes(kind)) { + throw new CliError(`--kind must be one of: ${AI_PROVIDER_KINDS.join(", ")}`); + } + if (!MODEL_ALIAS_RE.test(alias)) { + throw new CliError(`--alias must match ${MODEL_ALIAS_RE}`); + } + const model = upstreamModel.trim(); + if (!model) throw new CliError("--model must be a non-empty upstream model id"); + + return { + kind, + models: { + [alias]: { + upstreamModel: model, + protocol: "responses", + transports: ["http", "sse"], + inputModalities: ["text"], + outputModalities: ["text"], + capabilities: { + functionTools: false, + structuredOutput: false, + reasoning: false, + previousResponseId: false, + providerTools: false, + binaryFrames: false, + }, + }, + }, + }; +} + +/** + * Write a new provider file below the project root without replacing an + * existing path or following a parent directory outside the project. + * @param {string} cwd + * @param {string} file + * @param {ReturnType} provider + */ +export function writeAiProviderFile(cwd, file, provider) { + if (typeof file !== "string" || !file.trim()) throw new CliError("provider output file must not be empty"); + if (!existsSync(cwd)) throw new CliError(`working directory ${escapeTerminalText(cwd)} does not exist`); + + let root; + try { + root = realpathSync(cwd); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); + throw new CliError(`cannot resolve working directory: ${escapeTerminalText(message)}`); + } + const candidate = path.resolve(root, file); + if (!isPathInside(root, candidate)) throw new CliError("provider output file must stay inside the project"); + + const parent = path.dirname(candidate); + let resolvedParent; + try { + resolvedParent = realpathSync(parent); + } catch (err) { + const message = err instanceof Error && err.message ? err.message : String(err); + throw new CliError(`cannot resolve provider output directory: ${escapeTerminalText(message)}`); + } + if (!isPathInside(root, resolvedParent)) { + throw new CliError("provider output file must stay inside the project"); + } + + const output = path.join(resolvedParent, path.basename(candidate)); + try { + writeFileSync(output, `${JSON.stringify(provider, null, 2)}\n`, { encoding: "utf8", flag: "wx" }); + } catch (err) { + if (err instanceof Error && /** @type {{ code?: unknown }} */ (err).code === "EEXIST") { + throw new CliError(`provider output file ${escapeTerminalText(file)} already exists; refusing to overwrite it`); + } + const message = err instanceof Error && err.message ? err.message : String(err); + throw new CliError(`cannot write provider output file ${escapeTerminalText(file)}: ${escapeTerminalText(message)}`); + } + return path.relative(root, output) || path.basename(output); +} + +/** @param {string} provider */ +function validateProviderName(provider) { + if (!PROVIDER_NAME_RE.test(provider)) { + throw new CliError(`provider name must match ${PROVIDER_NAME_RE}`); + } +} diff --git a/lib/command.js b/lib/command.js index 9b9c134..58d2f7c 100644 --- a/lib/command.js +++ b/lib/command.js @@ -126,11 +126,11 @@ function buildParseOptions(options) { * options?: Array, * sensitiveInput?: { commandPaths: string[][] }, * defaults?: Record, - * autoloadEnv?: boolean, + * autoloadEnv?: boolean | ((positionals: string[]) => boolean), * usage: () => string, * run(ctx: { values: Record, positionals: string[], context: CommandContext }): Promise | unknown, * }} spec - * @returns {{ main: (argv?: string[]) => Promise, run: (argv?: string[], deps?: object) => Promise, meta: { name: string, summary: string, autoloadEnv: boolean, parseOptions: import("node:util").ParseArgsOptionsConfig } }} + * @returns {{ main: (argv?: string[]) => Promise, run: (argv?: string[], deps?: object) => Promise, meta: { name: string, summary: string, autoloadEnv: boolean | ((positionals: string[]) => boolean), parseOptions: import("node:util").ParseArgsOptionsConfig } }} */ export function defineCommand(spec) { const { name, summary, options = [], sensitiveInput, defaults = {}, autoloadEnv = true, usage, run } = spec; diff --git a/lib/stdin.js b/lib/stdin.js index 2597d5b..3507911 100644 --- a/lib/stdin.js +++ b/lib/stdin.js @@ -1,6 +1,5 @@ -// Reading from stdin/TTY: confirmAction (interactive [y/N] gate), -// readTtyLine (raw-mode hidden input, fail-closed), and readSecretStdin -// (the secret reader shared by `wdl token set` and `wdl secret put`). +// Reading from stdin/TTY: confirmation, visible single/multi-line prompts, +// raw-mode hidden input, and the secret reader shared by token/secret commands. import { CliError } from "./common.js"; import { escapeTerminalText } from "./output.js"; @@ -131,6 +130,91 @@ export function readTtyLine(stdin, { prompt, stderr, hidden = false } = {}) { }); } +/** + * Read several visible TTY answers through one listener. A single reader keeps + * pasted multi-line answers buffered between prompts; repeated readTtyLine() + * calls would pause the stream and discard data after the first newline. + * @param {StdinLike} stdin + * @param {{ prompts: Array string)>, stderr?: (text: string) => void }} options + * @returns {Promise} + */ +export function readTtyLines(stdin, { prompts, stderr }) { + if (!stdin.isTTY) throw new CliError("interactive input requires a TTY"); + if (prompts.length === 0) return Promise.resolve([]); + + return new Promise((resolve, reject) => { + /** @type {string[]} */ + const answers = []; + let current = ""; + let skipLeadingLf = false; + + const cleanup = () => { + stdin.off("data", onData); + stdin.off("end", onEnd); + stdin.off("error", onError); + if (typeof stdin.pause === "function") stdin.pause(); + }; + /** @param {string[]} value */ + const finish = (value) => { + cleanup(); + resolve(value); + }; + /** @param {unknown} err */ + const fail = (err) => { + cleanup(); + reject(err); + }; + const writePrompt = () => { + const prompt = prompts[answers.length]; + if (stderr) stderr(escapeTerminalText(typeof prompt === "function" ? prompt(answers) : prompt)); + }; + const submit = () => { + answers.push(current); + current = ""; + if (answers.length === prompts.length) { + finish(answers); + return true; + } + writePrompt(); + return false; + }; + + /** @param {string} chunk */ + const onData = (chunk) => { + for (const ch of chunk) { + if (skipLeadingLf) { + skipLeadingLf = false; + if (ch === "\n") continue; + } + if (ch === "\u0003") return fail(new CliError("input aborted")); + if (ch === "\r") { + skipLeadingLf = true; + if (submit()) return; + continue; + } + if (ch === "\n") { + if (submit()) return; + continue; + } + current += ch; + } + }; + const onEnd = () => { + if (current || answers.length + 1 === prompts.length) answers.push(current); + if (answers.length === prompts.length) finish(answers); + else fail(new CliError("input ended before all answers were provided")); + }; + /** @param {unknown} err */ + const onError = (err) => fail(err); + + stdin.setEncoding("utf8"); + writePrompt(); + stdin.on("data", onData); + stdin.on("end", onEnd); + stdin.on("error", onError); + }); +} + // Read a single secret value from stdin: a TTY prompts with hidden (raw-mode, // non-echoing) input; a pipe/redirect is read to EOF with one trailing newline // trimmed, so `printf '%s' "$SECRET" | …` works. Shared by `wdl token set` and diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 05a32f5..778b9e8 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -63,6 +63,8 @@ Artifacts `triggers.events` subscriptions and R2 `local_dev.experimental_s3_credentials` are also unsupported and rejected. `[ai] binding = "AI"` declares the AI facade; provider metadata and credentials are namespace resources managed with `wdl ai`, never ordinary Worker secrets. +Use `wdl ai providers init ` to scaffold a conservative one-model +Responses config, then edit model-specific capabilities before `providers put`. Like other bindings, `[ai]` is not inherited into named environments; deploy warns when the selected environment omits a top-level AI binding. `[wdl] session_policy = "restart"` makes every promotion close the Worker's open diff --git a/tests/unit/cli-ai.test.js b/tests/unit/cli-ai.test.js index 1f1e9dc..bb59937 100644 --- a/tests/unit/cli-ai.test.js +++ b/tests/unit/cli-ai.test.js @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { Readable } from "node:stream"; @@ -218,6 +218,175 @@ test("ai providers put rejects invalid or out-of-project files before control", } }); +test("ai providers init writes a conservative config without contacting control", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-ai-provider-init-")); + /** @type {string[]} */ + const lines = []; + try { + await runAiCommand(["providers", "init", "openai"], { + cwd: dir, + env: {}, + stdin: /** @type {NodeJS.ReadStream} */ (/** @type {unknown} */ ({ isTTY: false })), + stdout: (/** @type {string} */ line) => lines.push(line), + controlFetch: async () => { + throw new Error("providers init must not contact control"); + }, + }); + + assert.deepEqual(JSON.parse(readFileSync(path.join(dir, "provider.openai.json"), "utf8")), { + kind: "openai", + models: { + primary: { + upstreamModel: "gpt-5.6-luna", + protocol: "responses", + transports: ["http", "sse"], + inputModalities: ["text"], + outputModalities: ["text"], + capabilities: { + functionTools: false, + structuredOutput: false, + reasoning: false, + previousResponseId: false, + providerTools: false, + binaryFrames: false, + }, + }, + }, + }); + assert.deepEqual(lines, [ + "Created provider.openai.json.", + "Review the generated modalities and capabilities before uploading it.", + "Next: wdl ai providers put 'openai' --file 'provider.openai.json' --ns ", + ]); + + for (const [provider, upstreamModel] of [ + ["xai", "grok-4.6"], + ["deepseek", "deepseek-v4-flash"], + ]) { + await runAiCommand(["providers", "init", provider], { + cwd: dir, + env: {}, + stdin: /** @type {NodeJS.ReadStream} */ (/** @type {unknown} */ ({ isTTY: false })), + stdout: () => {}, + }); + const generated = JSON.parse(readFileSync(path.join(dir, `provider.${provider}.json`), "utf8")); + assert.equal(generated.models.primary.upstreamModel, upstreamModel); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("ai providers init prompts for defaults and writes user overrides", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-ai-provider-prompt-")); + const answers = ["xai", "voice", "grok-voice", ""]; + /** @type {string[]} */ + const prompts = []; + try { + await runAiCommand(["providers", "init", "production"], { + cwd: dir, + env: {}, + stdin: /** @type {NodeJS.ReadStream} */ (/** @type {unknown} */ ({ isTTY: true })), + stdout: () => {}, + stderr: () => {}, + readLines: async ( + /** @type {unknown} */ _stdin, + /** @type {{ prompts: Array string)> }} */ options + ) => { + /** @type {string[]} */ + const entered = []; + for (const prompt of options.prompts) { + prompts.push(typeof prompt === "function" ? prompt(entered) : prompt); + entered.push(answers[entered.length]); + } + return entered; + }, + }); + + assert.deepEqual(prompts, [ + "Provider kind (openai/xai/deepseek) [openai]: ", + "Model alias [primary]: ", + "Upstream model id [grok-4.6]: ", + "Output file [provider.production.json]: ", + ]); + const body = JSON.parse(readFileSync(path.join(dir, "provider.production.json"), "utf8")); + assert.equal(body.kind, "xai"); + assert.deepEqual(body.models.voice, { + upstreamModel: "grok-voice", + protocol: "responses", + transports: ["http", "sse"], + inputModalities: ["text"], + outputModalities: ["text"], + capabilities: { + functionTools: false, + structuredOutput: false, + reasoning: false, + previousResponseId: false, + providerTools: false, + binaryFrames: false, + }, + }); + + await runAiCommand(["providers", "init", "openai"], { + cwd: dir, + env: {}, + stdin: /** @type {NodeJS.ReadStream} */ (/** @type {unknown} */ ({ isTTY: true })), + stdout: () => {}, + stderr: () => {}, + readLines: async ( + /** @type {unknown} */ _stdin, + /** @type {{ prompts: Array string)> }} */ options + ) => options.prompts.map(() => ""), + }); + const defaults = JSON.parse(readFileSync(path.join(dir, "provider.openai.json"), "utf8")); + assert.equal(defaults.kind, "openai"); + assert.equal(defaults.models.primary.upstreamModel, "gpt-5.6-luna"); + assert.equal(defaults.models.primary.protocol, "responses"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("ai providers init rejects invalid values, unsafe paths, and existing files", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-ai-provider-init-errors-")); + const stdin = /** @type {NodeJS.ReadStream} */ (/** @type {unknown} */ ({ isTTY: false })); + try { + await assert.rejects( + runAiCommand(["providers", "init", "custom", "--kind", "other"], { + cwd: dir, + env: {}, + stdin, + }), + /--kind must be one of/ + ); + await assert.rejects( + runAiCommand(["providers", "init", "openai", "--alias", "123"], { cwd: dir, env: {}, stdin }), + /--alias must match/ + ); + await assert.rejects( + runAiCommand(["providers", "init", "openai", "--model", "gpt-5", "--file", "../outside.json"], { + cwd: dir, + env: {}, + stdin, + }), + /provider output file must stay inside the project/ + ); + + writeFileSync(path.join(dir, "provider.openai.json"), "keep\n"); + await assert.rejects( + runAiCommand(["providers", "init", "openai", "--model", "gpt-5"], { + cwd: dir, + env: {}, + stdin, + }), + /already exists; refusing to overwrite it/ + ); + assert.equal(readFileSync(path.join(dir, "provider.openai.json"), "utf8"), "keep\n"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("ai credential put reads a hidden credential and CASes the current provider revision", async () => { /** @type {ControlCall[]} */ const calls = []; diff --git a/tests/unit/cli-dispatcher.test.js b/tests/unit/cli-dispatcher.test.js index 767b96a..b7aacca 100644 --- a/tests/unit/cli-dispatcher.test.js +++ b/tests/unit/cli-dispatcher.test.js @@ -231,6 +231,35 @@ test("wdl dispatcher skips dotenv when help is requested", async () => { assert.deepEqual(calls, []); }); +test("wdl dispatcher skips credential autoload for local AI provider init", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-ai-init-dispatch-")); + const oldCwd = process.cwd(); + const oldLog = console.log; + /** @type {string[]} */ + const loadCalls = []; + process.chdir(dir); + console.log = () => {}; + try { + await wdlMain(["ai", "providers", "init", "openai"], { + env: {}, + loadEnv: /** @type {LoadEnvFn} */ ( + /** @type {unknown} */ ( + () => { + loadCalls.push("loaded"); + throw new Error("provider init must not load .env"); + } + ) + ), + }); + assert.equal(JSON.parse(readFileSync(path.join(dir, "provider.openai.json"), "utf8")).kind, "openai"); + } finally { + process.chdir(oldCwd); + console.log = oldLog; + rmSync(dir, { recursive: true, force: true }); + } + assert.deepEqual(loadCalls, []); +}); + test("wdl dispatcher reports a malformed .env without a Node stack", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-dispatch-env-")); const oldCwd = process.cwd(); diff --git a/tests/unit/cli-stdin.test.js b/tests/unit/cli-stdin.test.js index fbfab48..0021dc2 100644 --- a/tests/unit/cli-stdin.test.js +++ b/tests/unit/cli-stdin.test.js @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { EventEmitter } from "node:events"; -import { confirmAction, readSecretStdin, readTtyLine } from "../../lib/stdin.js"; +import { confirmAction, readSecretStdin, readTtyLine, readTtyLines } from "../../lib/stdin.js"; const ESC = String.fromCharCode(27); @@ -101,6 +101,24 @@ test("readTtyLine escapes terminal controls in the prompt at the write point", a assert.doesNotMatch(errs.join(""), new RegExp(ESC), "raw ESC from the prompt must not reach stderr"); }); +test("readTtyLines preserves pasted answers across prompts", async () => { + /** @type {string[]} */ + const prompts = []; + const stdin = Object.assign(new EventEmitter(), { + isTTY: true, + setEncoding() {}, + pause() {}, + }); + const pending = readTtyLines(stdin, { + prompts: ["kind: ", (answers) => `model [${answers[0]}]: `, "alias: "], + stderr: (text) => prompts.push(text), + }); + queueMicrotask(() => stdin.emit("data", "openai\nprimary\r\ngpt-5\n")); + + assert.deepEqual(await pending, ["openai", "primary", "gpt-5"]); + assert.deepEqual(prompts, ["kind: ", "model [openai]: ", "alias: "]); +}); + test("confirmAction escapes terminal controls in its refusal message", async () => { const esc = String.fromCharCode(27); await assert.rejects( From 593e4dfd60cd3fcd2096de9c14906b864df5adf9 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 17 Aug 2026 17:17:59 +0800 Subject: [PATCH 5/5] Clarify AI provider capability gates Document the fail-closed input, continuation, and binary-frame checks. Explain that the bundled agent demo already enables previous-response continuation. Keep English, Chinese, skill, and generated-project guidance aligned. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 8 +++++++- GUIDE-zh.md | 2 +- GUIDE.md | 9 +++++++-- docs/ai-zh.md | 4 ++-- docs/ai.md | 17 ++++++++++++----- templates/AGENTS.md | 3 +++ 6 files changed, 32 insertions(+), 11 deletions(-) diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index a020c2b..4818e0c 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -118,7 +118,13 @@ without loading credentials or contacting Control. It offers editable defaults for kind, the `primary` alias, model id, and output filename. It pre-fills `gpt-5.6-luna`, `grok-4.6`, or `deepseek-v4-flash` for the matching adapter and emits a conservative text-only Responses descriptor over HTTP/SSE. It refuses to -overwrite files; edit the JSON for model-specific protocols or capabilities. +overwrite files. The defaults reject non-text input, `previous_response_id` +continuation, and binary WebSocket frames until the matching `inputModalities`, +`previousResponseId`, or `binaryFrames` declaration is enabled. The bundled AI +agent demo needs `previousResponseId: true`, which its provider file already +declares. Other capability flags are catalog declarations and do not currently +gate WDL requests; edit the JSON for other model-specific protocols or +capabilities. `wdl ai providers put` replaces the complete provider record and accepts only a project-contained `{ kind, models }` file; omitted model aliases are removed. diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 25e4152..b9ca04d 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -380,7 +380,7 @@ printf '%s' "$OPENAI_API_KEY" | wdl ai credential put openai wdl ai models ``` -`providers init` 是生成单模型 provider 文件的离线交互式脚手架。它会为 provider kind、`primary` alias、upstream model id 和 `provider..json` 文件名提供可修改的默认值并拒绝覆盖已有文件。它会为对应 adapter 预填 `gpt-5.6-luna`、`grok-4.6` 或 `deepseek-v4-flash`,并只生成 text-only Responses + HTTP/SSE 的保守 descriptor;其他 model-specific protocol 或 capability 需要直接编辑 JSON。Control 仍然是 canonical validator。 +`providers init` 是生成单模型 provider 文件的离线交互式脚手架。它会为 provider kind、`primary` alias、upstream model id 和 `provider..json` 文件名提供可修改的默认值并拒绝覆盖已有文件。它会为对应 adapter 预填 `gpt-5.6-luna`、`grok-4.6` 或 `deepseek-v4-flash`,并只生成 text-only Responses + HTTP/SSE 的保守 descriptor;默认配置会拒绝非文本输入、`previous_response_id` 续写和二进制 WebSocket frame,直到启用对应的 `inputModalities`、`previousResponseId` 或 `binaryFrames` 声明。内置 AI agent demo 需要 `previousResponseId: true`,其自带 provider 文件已经声明。其余 capability 标志只是 catalog 声明,WDL 当前不会据此拒绝请求。其他 model-specific protocol 或 capability 需要直接编辑 JSON。Control 仍然是 canonical validator。 Provider 元数据选择官方 adapter(`openai`、`xai` 或 `deepseek`),并把 `openai/primary` 这类有界 alias 映射到原生 model id 和 capability。更新元数据会生成新 revision;同 kind 更新保留既有 credential,切换 adapter kind 才会清除 credential 并要求在推理前重新配置。凭据是加密的 namespace 资源,不会进入 Worker env 或 bundle metadata。 diff --git a/GUIDE.md b/GUIDE.md index e19a75a..e139d1a 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -582,8 +582,13 @@ file. It offers editable defaults for the provider kind, `primary` alias, upstream model id, and `provider..json` filename, and refuses to overwrite. It pre-fills `gpt-5.6-luna`, `grok-4.6`, or `deepseek-v4-flash` for the matching adapter and emits a conservative text-only Responses descriptor -over HTTP/SSE. Edit the JSON for model-specific protocols or capabilities; -Control remains the canonical validator. +over HTTP/SSE. Its defaults reject non-text input, `previous_response_id` +continuation, and binary WebSocket frames until the matching `inputModalities`, +`previousResponseId`, or `binaryFrames` declaration is enabled. The bundled AI +agent demo needs `previousResponseId: true`, which its checked-in provider file +already declares. The other capability flags are catalog declarations; WDL does +not currently reject requests based on them. Edit the JSON for model-specific +protocols or capabilities; Control remains the canonical validator. Provider metadata selects one official adapter (`openai`, `xai`, or `deepseek`) and maps bounded aliases such as `openai/primary` to native model ids and diff --git a/docs/ai-zh.md b/docs/ai-zh.md index 601fe47..818d2cc 100644 --- a/docs/ai-zh.md +++ b/docs/ai-zh.md @@ -33,7 +33,7 @@ wdl ai providers init openai 这个本地 initializer 会为 provider kind、model alias、upstream model id 和输出文件名提供默认值,并允许交互用户逐项修改;已有文件不会被覆盖。非交互 shell 执行同一条命令时也会使用这些默认值。 -名称为 `openai`、`xai` 或 `deepseek` 的 provider 会选择同名 kind,其他名称默认使用 `openai`;可以用 `--kind` 覆盖。`--alias` 和 `--file` 可覆盖默认的 `primary` alias 与文件名。Initializer 会为 OpenAI、xAI 和 DeepSeek 分别预填 `gpt-5.6-luna`、`grok-4.6` 和 `deepseek-v4-flash`,也可以用 `--model` 覆盖这些起始值。它只生成 text-only Responses + HTTP/SSE 的保守 descriptor,并关闭全部可选 capability;其他 protocol、transport、modality 或 capability 需要直接编辑 JSON。Initializer 完全离线,不读取 WDL credential,也不访问 Control;Control 仍然是 canonical validator。 +名称为 `openai`、`xai` 或 `deepseek` 的 provider 会选择同名 kind,其他名称默认使用 `openai`;可以用 `--kind` 覆盖。`--alias` 和 `--file` 可覆盖默认的 `primary` alias 与文件名。Initializer 会为 OpenAI、xAI 和 DeepSeek 分别预填 `gpt-5.6-luna`、`grok-4.6` 和 `deepseek-v4-flash`,也可以用 `--model` 覆盖这些起始值。它只生成 text-only Responses + HTTP/SSE 的保守 descriptor,并关闭全部可选 capability;默认配置会 fail closed 地拒绝非文本输入、`previous_response_id` 续写和二进制 WebSocket frame,使用这些功能前需要分别启用对应的 `inputModalities`、`previousResponseId` 或 `binaryFrames` 声明。其余 capability 标志只是 catalog 声明,WDL 当前不会据此拒绝请求。其他 protocol、transport、modality 或 capability 也需要直接编辑 JSON。Initializer 完全离线,不读取 WDL credential,也不访问 Control;Control 仍然是 canonical validator。 生成文件与手写文件使用相同的可写形状: @@ -224,7 +224,7 @@ WDL 当前不提供托管模型凭据、持久用量统计、消费 quota、AI G ## 端到端示例 -`../examples/ai-agent-demo` 展示由 bearer token 保护的 Responses function-tool 循环。写入 provider 文件,配置 provider credential 和 demo 的 Worker 级访问 token 并部署 Worker 后,再 POST prompt: +`../examples/ai-agent-demo` 展示由 bearer token 保护的 Responses function-tool 循环。该循环使用 `previous_response_id` 续写,因此自带 provider 文件已启用 `previousResponseId`;如果用 initializer 输出替换该文件,需要先启用这项 capability。写入 provider 文件,配置 provider credential 和 demo 的 Worker 级访问 token 并部署 Worker 后,再 POST prompt: ```bash cd examples/ai-agent-demo diff --git a/docs/ai.md b/docs/ai.md index decd8d1..8562aad 100644 --- a/docs/ai.md +++ b/docs/ai.md @@ -59,9 +59,13 @@ inferred kind, `primary` alias, and default filename. The initializer pre-fills DeepSeek; use `--model` to override these starting values. It emits a conservative text-only Responses descriptor over HTTP/SSE with all optional capabilities disabled. Edit the JSON when the selected model needs another -protocol, transport, modality, or capability. The initializer is offline and -does not read WDL credentials or contact Control; Control remains the canonical -validator. +protocol, transport, modality, or capability. The defaults fail closed for +non-text input, `previous_response_id` continuation, and binary WebSocket +frames; enable the matching `inputModalities`, `previousResponseId`, or +`binaryFrames` declaration before using those features. The other capability +flags are catalog declarations; WDL does not currently reject requests based on +them. The initializer is offline and does not read WDL credentials or contact +Control; Control remains the canonical validator. The generated file has the same writable shape as a manually authored file: @@ -308,8 +312,11 @@ Responses/webhooks, provider file APIs, WebRTC, or SIP. ## End-to-end example `../examples/ai-agent-demo` demonstrates a Responses function-tool loop behind a -bearer token. Put the provider file, configure its credential and the demo's -Worker-level access token, deploy the Worker, then POST a prompt: +bearer token. Its checked-in provider file enables `previousResponseId` because +the tool loop continues with `previous_response_id`; enable that capability if +you replace the file with initializer output. Put the provider file, configure +its credential and the demo's Worker-level access token, deploy the Worker, then +POST a prompt: ```bash cd examples/ai-agent-demo diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 778b9e8..517cb75 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -65,6 +65,9 @@ Artifacts `triggers.events` subscriptions and R2 are namespace resources managed with `wdl ai`, never ordinary Worker secrets. Use `wdl ai providers init ` to scaffold a conservative one-model Responses config, then edit model-specific capabilities before `providers put`. +Initializer output rejects non-text input, `previous_response_id` continuation, +and binary WebSocket frames until their corresponding declarations are enabled; +the bundled AI agent demo already enables its required `previousResponseId`. Like other bindings, `[ai]` is not inherited into named environments; deploy warns when the selected environment omits a top-level AI binding. `[wdl] session_policy = "restart"` makes every promotion close the Worker's open