diff --git a/.claude/rules/examples.md b/.claude/rules/examples.md index d231a82..f889dd7 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 @@ -77,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 @@ -92,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 c58229b..4818e0c 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 @@ -65,27 +67,31 @@ 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`, 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 -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 @@ -101,6 +107,37 @@ 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`. + +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. 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. +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 +`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/CHANGELOG.md b/CHANGELOG.md index da66784..7cd63b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +### Added + +- 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 + +- `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 ### Changed diff --git a/GUIDE-zh.md b/GUIDE-zh.md index afd91b1..b9ca04d 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` 字段仍然有效。 @@ -257,13 +259,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` 和 `[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。 @@ -277,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、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 @@ -361,6 +364,51 @@ 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 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;默认配置会拒绝非文本输入、`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。 + +`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 形态: + +```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 和取消。`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)。 + ### D1 部署绑定 D1 的 Worker 前,先创建数据库: @@ -885,10 +933,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..e139d1a 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 @@ -369,34 +376,39 @@ 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 | - -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. +| 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 consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, +`[[services]].ns`, and `[wdl]` itself and removes those WDL extensions from the +temporary config passed to the Wrangler bundler. `[ai]` is standard Wrangler +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. Cron triggers and queue consumers are dispatch features. Declare them only on routeable Workers in tenant namespaces unless your operator gives you an @@ -438,10 +450,11 @@ 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 -applying unless the env declares its own. For staging and production side by -side, use separate namespaces unless your operator tells you otherwise. +top-level `[vars]`, KV, D1, R2, AI, queues, services, or workflows into that +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 @@ -547,6 +560,85 @@ 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 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. 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 +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. + +`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 +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: + +```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. +`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 +[`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 +1331,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..11920d2 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..fc2cec7 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..f71f7e8 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, @@ -60,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 */ @@ -105,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, @@ -152,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, }; } @@ -168,7 +175,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..151ecb2 --- /dev/null +++ b/commands/ai.js @@ -0,0 +1,413 @@ +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, + defineCliOption, + formatHelp, + isMain, + isPathInside, + optionHelp, + readJsonOrFailWithHint, + redactedArgumentError, + sensitiveInputArgumentError, +} from "../lib/common.js"; +import { escapeTerminalText, shellSingleQuote, writeJsonOr, writeStatusLine } from "../lib/output.js"; +import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; +import { confirmAction, readSecretStdin, readTtyLines } from "../lib/stdin.js"; + +const AI_OPTIONS = [ + 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", + "json", + "help", +]; + +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"], + ["providers", "init"], + ["credential", "put"], + ], + }, + defaults: { readLines: readTtyLines }, + autoloadEnv: (positionals) => !isProviderInitCommand(positionals), + 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, + * kind?: string, + * model?: string, + * alias?: string, + * yes?: boolean, + * }} AiFlags + */ + +/** @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()); + + if (group === "models") { + 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") + ); + 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, + `${String(model.id ?? "-")} protocol=${String(model.protocol ?? "-")} transports=${transports}` + ); + } + return; + } + + if (group === "providers" && action === "list") { + 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") + ); + 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, + `${String(entry.name ?? "-")} kind=${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 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: ${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: ${alias} (${String(protocol ?? "-")})`); + } + return; + } + + if (group === "providers" && action === "put") { + requireProvider(provider, "ai providers put"); + if (extraArg) throw redactedArgumentError("ai providers put"); + 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; + const credentialStatus = + body.provider?.credentialConfigured === true + ? "existing credential preserved" + : "credential not configured; configure it before use"; + writeStatusLine(stdout, `OK AI provider ${provider} saved; ${credentialStatus}`); + return; + } + + if (group === "providers" && action === "delete") { + requireProvider(provider, "ai providers delete"); + if (extraArg) throw redactedArgumentError("ai providers delete"); + 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 ${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 sensitiveInputArgumentError("ai credential put"); + const { headers } = context.resolveControl(); + const providerBody = /** @type {AiProviderResponse} */ ( + await fetchAiJsonWithHint( + context, + context.nsUrl("ai", "providers", provider), + { headers }, + "prepare AI credential", + aiCredentialPreflightHint + ) + ); + 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 fetchAiJsonWithHint( + context, + context.nsUrl("ai", "providers", provider, "credential"), + { + method: "PUT", + headers: { ...headers, "content-type": "application/json" }, + body: JSON.stringify({ revision, credential }), + }, + "put AI credential", + aiCredentialMutationHint + ); + if (writeJsonOr(values.json === true, body, stdout)) return; + writeStatusLine(stdout, `OK AI credential configured for ${provider}`); + return; + } + + 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 */ +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 "); + 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; +} + +/** + * @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 */ + +function usageText() { + return formatHelp({ + 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] ", + "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/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/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..818d2cc --- /dev/null +++ b/docs/ai-zh.md @@ -0,0 +1,243 @@ +# 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 隔离。 + +最不易误用的方式是使用 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 的生命周期一致。 + +常见的单模型配置可以通过交互方式生成项目内的 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;默认配置会 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。 + +生成文件与手写文件使用相同的可写形状: + +```json +{ + "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 + } + } + } +} +``` + +然后分别写入元数据和凭据: + +```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。同一官方 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 协议 | +| --- | --- | --- | +| `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 init [options] +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] +``` + +`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 后,下一次调用也会立即生效。 + +`providers delete` 默认会提示确认,并同时删除 provider metadata 和 credential。该命令没有 dry-run。先运行 `wdl config explain` 确认最终解析出的 namespace,再用 `wdl ai providers get --ns ` 查看目标,并在删除时传入同一个显式 `--ns`。只有完成这项独立检查并与用户确认后,才能传 `--yes`。 + +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 保持活跃。 + +如果应用把 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、日志或请求参数。 +- 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` 展示由 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 +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 +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 new file mode 100644 index 0000000..8562aad --- /dev/null +++ b/docs/ai.md @@ -0,0 +1,337 @@ +# 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. + +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 +namespace has zero deployed Workers, matching namespace-secret lifecycle. + +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 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: + +```json +{ + "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 + } + } + } +} +``` + +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. 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. + +`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 | +| ---------- | -------------------------------------------------- | ---------------------------------- | +| `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 init [options] +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] +``` + +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. + +`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()` + +`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. + +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, + 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 behind a +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 +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 +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 a8302e4..5870543 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` 和 `[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 映射。 @@ -138,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 ffa6c45..c654882 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,22 @@ 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 consumes `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, +`[[services]].ns`, and `[wdl]` itself and removes those WDL extensions from the +temporary config passed to the Wrangler bundler. `[ai]` is standard Wrangler +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. WDL also rejects Cloudflare Artifacts `triggers.events` subscriptions and R2 `local_dev.experimental_s3_credentials`: neither field has a WDL deploy-manifest @@ -279,10 +285,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/docs/env-overrides-zh.md b/docs/env-overrides-zh.md index 72e0be7..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 等不会自动继承到该 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 会明确提示。 按上面的例子: @@ -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..dfa1465 100644 --- a/docs/env-overrides.md +++ b/docs/env-overrides.md @@ -89,9 +89,10 @@ 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.]`. +`[env.]`. Deploy emits a warning when a top-level `[ai]` binding is not +redeclared in the selected environment. With the example above: @@ -125,8 +126,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/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/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..ecea650 --- /dev/null +++ b/examples/ai-agent-demo/provider.openai.json @@ -0,0 +1,20 @@ +{ + "kind": "openai", + "models": { + "primary": { + "upstreamModel": "gpt-5.6-luna", + "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..f0d11bf --- /dev/null +++ b/examples/ai-agent-demo/src/index.js @@ -0,0 +1,136 @@ +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, +}; + +const MAX_TOOL_ROUNDS = 8; +const encoder = new TextEncoder(); + +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(""); +} + +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 (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) { + let response = await ai.run("openai/primary", { + input: prompt, + tools: [TIME_TOOL], + tool_choice: "auto", + reasoning: { effort: "low" }, + }); + 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 { + 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 } with Authorization: Bearer ", + models: await env.AI.models(), + }); + } + if (request.method !== "POST") return json({ error: "method_not_allowed" }, { status: 405 }); + 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 }); + } 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..3edede1 --- /dev/null +++ b/examples/ai-agent-demo/wrangler.toml @@ -0,0 +1,6 @@ +name = "ai-agent-demo" +main = "src/index.js" +compatibility_date = "2026-06-17" + +[ai] +binding = "AI" 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 169ce3a..58d2f7c 100644 --- a/lib/command.js +++ b/lib/command.js @@ -18,6 +18,7 @@ import { optionParseOptions, printHelpIfRequested, readJsonOrFail, + redactedArgumentError, runCliMain, throwHttpErrorIfNotOk, } from "./common.js"; @@ -115,19 +116,24 @@ 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, + * 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 = [], 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/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/lib/wrangler-pack.js b/lib/wrangler-pack.js index 87eec66..f807318 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, @@ -30,6 +31,7 @@ import { import { collectRoutes, createWranglerBundleConfig, + formatAiEnvNonInheritanceWarning, formatWranglerConfigShadowWarning, loadWranglerConfig, parseSessionPolicy, @@ -46,6 +48,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, @@ -61,6 +64,7 @@ export { parseWranglerMajorVersion, resolveWranglerCommand, wranglerChildEnv } f export { collectRoutes, createWranglerBundleConfig, + formatAiEnvNonInheritanceWarning, formatWranglerConfigShadowWarning, loadWranglerConfig, parseJsonc, @@ -157,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. @@ -194,6 +201,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..ed421f3 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", @@ -165,10 +165,32 @@ 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. - * 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} */ diff --git a/templates/AGENTS.md b/templates/AGENTS.md index ec8afb7..517cb75 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,15 @@ 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. +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 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 +88,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 +96,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 +132,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/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 new file mode 100644 index 0000000..bb59937 --- /dev/null +++ b/tests/unit/cli-ai.test.js @@ -0,0 +1,578 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, 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 { ESC, assertNoRawTerminalControls, 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 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 { + 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), + 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; 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 }); + } +}); + +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 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 = []; + /** @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 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[]} */ + 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 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"], { + env: { ADMIN_TOKEN: "tok" }, + }), + /requires / + ); + await assert.rejects( + 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 f00fbc7..29f5b93 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.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")); @@ -388,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-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-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-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( 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-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..72c12fd 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" }); @@ -306,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( { @@ -429,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", @@ -451,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: { @@ -462,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" }, }, }, @@ -474,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" }); @@ -493,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); }); @@ -752,7 +774,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 +788,6 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy for (const key of [ "addresses", "agent_memory", - "ai", "artifacts", "browser", "cache",