diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d94b870..bbee36ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,7 @@ jobs: - uses: astral-sh/setup-uv@v10.2.0 - run: uv sync --locked --project python - run: uv run --locked --project python python tools/check_spec.py + - run: uv run --locked --project python python tools/make_models.py --check # Every conformance case, through the command line of a fresh grida-fx, with the repository's # Python SDK and its built @grida/fx hosting the node bodies. --strict: a case without reviewed diff --git a/AGENTS.md b/AGENTS.md index d1b75f7c..5e7d5c1a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Guardrails for working in this repository. [README.md](README.md) says what FX i | `fixtures/viewer/` | Canonical provider-free development harness for viewer topology, media, cache, takes, failures and recorded in-progress states; generated data stays outside the source fixture. | | `fixtures/host/grida/` | A stand-in for the npm `grida` command reduced to its `fx` verb; `tools/check_host.py` installs it beside packed `@grida/fx` and runs conformance through `grida fx`. | | `fixtures/control/` | Provider-free checkpoint and delayed-cleanup harness for cancellation acceptance, waiting, verified cleanup and resume. Copy it into a fresh project for each test. | -| `tools/` | What runs outside the engine: the spec gate, the independent digest checker, packaging, `build_engine.py` (the engine into a checkout's SDK) and `check_examples.py`. | +| `tools/` | What runs outside the engine: the spec gate, the independent digest checker, packaging, `build_engine.py` (the engine into a checkout's SDK), `check_examples.py` and `make_models.py` (the pinned Grida Models facts the built-in routes are checked against). | ## Rules @@ -74,6 +74,7 @@ cargo fmt --check && cargo clippy --all-targets -- -D warnings && cargo test --w (cd python && uv run ruff check --config pyproject.toml . ../tools ../conformance ../examples ../fixtures && uv run ruff format --check --config pyproject.toml . ../tools ../conformance/run.py ../examples ../fixtures && uv run pytest) (cd js/fx && bun --no-env-file run typecheck && bun --no-env-file test) uv run --project python python tools/check_spec.py +uv run --project python python tools/make_models.py --check uv run --project python python conformance/run.py --command target/debug/grida-fx --js-sdk js/fx python3 tools/build_engine.py && uv run --project python python tools/check_examples.py uv run --project python python tools/check_llms.py diff --git a/AGENT_READINESS.md b/AGENT_READINESS.md index b52ed294..72e7772f 100644 --- a/AGENT_READINESS.md +++ b/AGENT_READINESS.md @@ -10,12 +10,14 @@ This is a living product and engineering harness. It records current behavior, gaps and scenarios against which we design FX. It is not a claim that every scenario below works today, nor approval to implement every proposed capability. -**Last source review: 2026-10-13.** Current entries were checked against the CLI, +**Last source review: 2026-10-15.** Current entries were checked against the CLI, SDKs and specifications. The cancellation delivery includes provider-free execution evidence from `tools/check_control.py`; run listing and removal rest on the `runs` CLI tests, the `runs-remove` and `cache-prune` conformance cases and the SDK tests; the TypeScript node host rests on the `@grida/fx` host tests, the engine's host tests and the TypeScript conformance -cases; other capability status rests on the evidence listed below. No live providers were used. +cases; models and providers FX does not ship (AR-20) rest on the replay, adapter-host and SDK +tests and the conformance cases its row names; other capability status rests on the evidence +listed below. No live providers were used. Recheck the installed version before applying them to a user's installation. ## Ownership and status @@ -52,6 +54,12 @@ Existing tests are starting points for evidence, not proof of every scenario her | Cache reuse | Reuse under FX's identity and validation rules. The engine decides which work matches; the agent does not manufacture cache keys. | | Retry | Another engine-owned attempt within execution. It is not a new run, resume, or request for an artistic alternative. | | Provider call / attempt / job | A call is the requested capability work; an attempt is one engine-accounted try; a job is a provider operation that may remain pending beyond a local request. An unknown job outcome is not permission to submit it again. | +| Route / route table | A route is `model@provider` for one capability, with a price or a cap and a contract; a project's own route table adds or restates routes. A new route is a new cache key; repricing one is not. | +| Cap | `price: {cap_usd: N}` on a route with no known price: each call holds N against the ceiling and is charged what the provider reports, else N. A reservation, not a limit the provider honours. | +| Provider options | `provider_options`, keyed by provider: a provider's own parameters, passed on a step or in a route's contract, for the adapter of the route's provider. Part of the call's identity; never a key. | +| Community provider | A provider a project declares in `fx.yaml`, not one FX ships. Distinct from a model FX does not list on a provider it ships, which needs only a route. | +| Provider declaration | The `providers:` entry naming a community provider's key variable, base URL, download hosts, and its `family` or `adapter`. A setting, not identity; the key itself never appears in it. | +| Adapter | The code that maps FX's request to a provider's wire and its answer back. FX's own are in the engine; a project adapter is a Python or TypeScript module (`adapter(...)`) run in an adapter host. The engine keeps the checks, the key, the ceiling, retries and records around both. | | Resume | Execute the same recorded plan again in its run folder, preserving recorded work and reusing valid results. It does not authorize changing that plan in place. | | Cancel | Ask the active invocation to stop. Completed work remains; local accounting can conservatively charge a full reservation without knowing the provider's final outcome. Exact-target CLI/Python/JavaScript control is implemented in source under spec/control.md. | | Terminate | Proposed explicit force operation with weaker cleanup guarantees; stopping local execution cannot prove remote termination. | @@ -91,7 +99,7 @@ narrower target rules. | Discover workflow inputs: `schema ` | JSON Schema for authored inputs | May load author code to resolve a target. | | Validate: `plan ... --check --json` | Expanded graph including problems; nonzero validation status | No FX paid calls; may import code, invoke builders, execute local `at: plan` nodes and write cache results. `--json` alone does not make plan problems a failing exit status. An image step's own request is checked against its route while planning (references need `image_input`, a `WxH` size `exact_size`, a transparent background `alpha`, and pictures within the route's `max_input_pictures`), so an agent learns at $0 that a route cannot take it. | | Inspect planned work: `expand`, `identity`, `price ...` | JSON graph, instance identities, or phased estimates | Planning conditions apply. Unresolved work and estimates are not proof of final work or actual billing. | -| Diagnose: `doctor [target]` | Text prerequisite, tool, route and key-presence diagnostics. The Python line says whether the node host's interpreter is ready, `too old` (FX needs Python 3.11+), has `no grida`, is `NOT FOUND`, or `cannot start`. The Node line after it says the same of the TypeScript node host's Node: ready (with its `@grida/fx` version), `too old` (FX needs Node 24+), `not Node.js 24+` (Bun, Deno), `no @grida/fx`, `NOT FOUND`, or `cannot start`. For that line, doctor starts the host only for a project with a `package.json` (the workflow's or the planning project), since its program may come from an `@grida/fx` above the project; without one, the line gives what Node answered, `(Node )`, or the Node alone. A `program` line per installed-program provider names the Codex or Grok Build a live run would start, where it was found and, for Codex, its version; a Codex older than 0.160.1 (or naming none) makes its routes `too old` (`no version`), as a live run refuses it. With a target, `uses` lines come first: each route its paid steps use (groups and used workflows included), whether it is servable, and how many steps use it | Let FX inspect credential configuration; agents must not read secret values. Missing keys alone do not make its exit status fail; a route the target uses that cannot be served does (exit 1), so `doctor ` answers "can this run live?" without matching routes by hand. Needs no inputs, and takes no `--routes`: a project that plans with extra route files lists their routes as `not in the catalog`. It does not make provider calls and is not a provider health check. CLI-only: an SDK caller sees a Python older than 3.11, or a Node older than 24, as the engine's refusal sentence. Neither line changes the exit status. | +| Diagnose: `doctor [target]` | Text prerequisite, tool, route and key-presence diagnostics. The Python line says whether the node host's interpreter is ready, `too old` (FX needs Python 3.11+), has `no grida`, is `NOT FOUND`, or `cannot start`. The Node line after it says the same of the TypeScript node host's Node: ready (with its `@grida/fx` version), `too old` (FX needs Node 24+), `not Node.js 24+` (Bun, Deno), `no @grida/fx`, `NOT FOUND`, or `cannot start`. For that line, doctor starts the host only for a project with a `package.json` (the workflow's or the planning project), since its program may come from an `@grida/fx` above the project; without one, the line gives what Node answered, `(Node )`, or the Node alone. An `adapter` line per declared project adapter gives its version and each capability's kind, as planning loads it, or `cannot load: `, which makes the exit status fail. A `program` line per installed-program provider names the Codex or Grok Build a live run would start, where it was found and, for Codex, its version; a Codex older than 0.160.1 (or naming none) makes its routes `too old` (`no version`), as a live run refuses it. With a target, `uses` lines come first: each route its paid steps use (groups and used workflows included), whether it is servable, and how many steps use it | Let FX inspect credential configuration; agents must not read secret values. Missing keys alone do not make its exit status fail; a route the target uses that cannot be served does (exit 1), so `doctor ` answers "can this run live?" without matching routes by hand. Needs no inputs, and takes no `--routes`: a project that plans with extra route files lists their routes as `not in the catalog`. It does not make provider calls and is not a provider health check. CLI-only: an SDK caller sees a Python older than 3.11, or a Node older than 24, as the engine's refusal sentence. Neither line changes the exit status. | | Execute: `run ... [--name NAME \| --run ]` | Foreground execution, an early run URL when inspection is available, and final summary | Local code needs no `--live`; paid calls need admission and a ceiling, except cached answers and runs whose every call is on the user's own Codex or Grok Build (`imagegen@codex`, `imagine@grok`, which need no ceiling but spend the user's plan, so still need their go). Choose a name or explicit folder when supervising asynchronously. `--name` refuses collisions; neither naming nor a fresh record forces fresh generation. Project run pages survive completion through the separately started service. `--open` requests browser launch; `--standalone` owns a temporary viewer instead; `--no-view` skips integration and conflicts with `--open`. | | Answer paid calls offline: `run --stand-in FILE#FUNCTION`; SDK `run(…, stand_in=…)` / `run(…, { standIn })` | A run whose paid calls the cache cannot answer are answered by a test's function, at $0 | Never live and runs every phase: `--live` and `--yes-up-to` are usage errors. The file is `.py`, `.ts`, `.mts`, `.js` or `.mjs`, hosted by its language's stand-in host; the SDKs serve the function in the caller's process. Answers pass a provider's checks; what the run stores is kept apart from the project's store, and it resumes only as a stand-in run. It shows what a workflow does with an answer, not how a provider behaves. | | Observe: `observe --snapshot`, `observe --after `, `project `, `inspect --json` | Consistent snapshot, bounded event batches, state projection or summary | Read recorded data without executing workflow code. A step queued for its route's slot is `waiting` (with `waiting_for`), not `running`. Use a precise folder or `inspect /`; `inspect ` chooses the newest-created run, including failures, and refuses ambiguous recorded sources. Keep the emitted folder for observation commands. | @@ -104,13 +112,13 @@ narrower target rules. | Clean up runs: `runs list --json`; `runs remove RUN… [--yes] --json` or `runs remove --workflow ID --state STATE --before DATE [--yes] --json` | The project's runs with placement and recorded state; what a removal would do, or did | Listing reads only. Removal previews until `--yes`; filters take dated runs only, never named, `--run` or external ones. Refuses a selection that is not a run (exit 2, nothing removed), a run an invocation holds (`active`) and the last run holding a pick (`holds_pick`). Removed files are mostly links into the cache, which keeps them. Python list_runs/remove_runs and JavaScript listRuns/removeRuns take explicit runs. | | Reclaim disk: `cache prune [--forget-user ID] [--yes] --json` | What would be (or was) removed per store, with the cost of the removed paid answers; or the refusals | Previews until `--yes`. Keeps everything a remaining run names; refuses (`shared_cache`, `job_outstanding`, `cache_in_use`, unreadable runs, interrupted older runs) rather than guess, and removes nothing then. `--forget-user` is the explicit statement that another project no longer uses the cache. CLI-only. | | Diagnose jobs: `jobs`; reconcile: `jobs --forget ` | Text job records; explicit deletion of a job record | Forgetting can permit a fresh paid submission. Use only after reconciling the provider outcome, not as generic failure recovery. | -| Maintain node locks: `lock [where] --check` / `lock [where]` | Lock verification / lockfile updates | `--same` asserts unchanged behavior; it is not a way to silence a real identity change. | +| Maintain node and adapter locks: `lock [where] --check` / `lock [where]` | Lock verification / lockfile updates | Versioned node types and versioned project adapters (`fx.lock`'s `adapters`). `--same #` asserts unchanged behavior; it is not a way to silence a real identity change. | | Deliver: `run ... --deliver output=path` or SDK result delivery | Copies of available declared outputs | CLI delivery happens after execution and reports missing outputs. Preserve immutable store/placed files; copy out before editing bytes. The viewer's **Download all (.zip)** is a viewer-only convenience for a person (the same files under their `outputs/` names); agents use `--deliver` or `export`, so it has no command or SDK form. | Python and JavaScript SDKs expose planning, named execution/resume, result files -and delivery. Each also hosts node bodies, builders and stand-ins in its language: -`grida` the Python ones, and `@grida/fx` the TypeScript ones, on Node 24 or later -([protocol.md](spec/protocol.md) §8). `load_run` / `loadRun` return a saved +and delivery. Each also hosts node bodies, builders, stand-ins and project +adapters in its language: `grida` the Python ones, and `@grida/fx` the TypeScript +ones, on Node 24 or later ([protocol.md](spec/protocol.md) §8). `load_run` / `loadRun` return a saved `RunRecord` with a pinned folder and engine inspection; its snapshot/events/follow methods observe through the public reader. Python provides sync/async reads and an async iterator; JavaScript provides promises and an async iterator. JavaScript's @@ -351,6 +359,7 @@ promise through documentation alone. | AR-17 | "Tidy up the layout; place X below Y." | Ratified design: [spec/layout.md](spec/layout.md). The automatic grid, decks and the layout report with automatic cells are released (0.2.0); the adjacent `fx-layout-v1` file the scenario edits is not implemented; visual editing deferred. | Agent discovers exact scope/references, edits and validates layout, verifies the renderer loaded the intended revision, and reports the actual URL or actionable diagnostics. No builder execution, provider calls, execution-history changes or identity/resume changes; repeats, nested scopes and stale references have provider-free evidence. | | AR-18 | "Clean up last week's exploratory runs, but keep my named ones." | Implemented in source: `runs list` and `runs remove` (CLI, Python, JavaScript), and `cache prune` (CLI). | Agent lists, previews and then removes exactly the previewed runs; named, `--run` and external runs survive filters; an active run, the last pick holder and a non-run selection are refused with a code and nothing else changes; catalog and run index forget removed folders; pruning keeps every remaining run resumable and refuses a shared, busy or unreadable cache, removing nothing; provider-free CLI, conformance and SDK evidence. | | AR-19 | "Share this result so somebody else can inspect it without my local service." | Implemented in source: terminal, inactive run export through CLI, Python and JavaScript; shared static browser reader and canvas. | Select the exact run, choose disclosure, export to a new directory, and review the receipt, omissions and concrete content. Verified selected bytes, non-overwriting placement, source stability and explicit reader failures preserve the evidence; publication remains a separate authorized consumer operation. | +| AR-20 | "Use this new model", or "use this provider FX does not ship." | Current in source: the [AI provider contract](docs/wg/provider-contract.md), taught end to end by the [Community providers](docs/guide/15-community-providers.md) guide and `llms.txt`'s recipes (a new model; a declared provider; a Python adapter), which `tools/check_llms.py` plans. A project route may name another model on a provider FX ships, may `extends:` a route to state only what differs (its contract then names the serving adapter, filled in when left out; `crates/grida-fx-core/tests/routes_spec.rs`, [conformance](conformance/route-extends/), [route vectors](spec/vectors/identity/routes.json)), and may give a per-call cap where it has no price (`price: {cap_usd: N}`: planned at $0 to N, held at N, booked at what the provider reports, else N; [conformance](conformance/capped-route/), settlement in `crates/grida-fx-runtime/tests/calls.rs`); the built-in routes are held to a pinned Grida Models projection (`crates/grida-fx-providers/tests/models.rs`); the adapter its contract names serves it, else the provider's default for the capability: on OpenRouter and fal images a generic adapter that sends the prompt, the pictures and the provider options and nothing chosen for one model, turning any answer into a PNG ([providers.md §2](spec/providers.md); replay tests on a made-up model, `crates/grida-fx-providers/tests/openrouter_generic_image.rs`, `crates/grida-fx-providers/tests/fal_generic_image.rs`), while a contract naming an adapter FX does not have is `no_route` and doctor's `no adapter`. fal's video adapter takes its route's tiers and length, and a request without a first frame only on a route that declares `text_to_video`; GG's image edits take at most the pictures their route declares (`max_input_pictures`). A step, a body's `ctx` call or a route's `contract` may pass parameters FX does not expose in `provider_options`, by provider: the serving adapter adds its provider's after its own members, and refuses at $0 one that would set a member it owns ([adapters.md §8](spec/adapters.md); replay tests per adapter in `crates/grida-fx-providers/tests/`). Deferred: an agent run (`agent.run`, both SDKs' `ctx.agent`) takes no `provider_options` of its own, since the engine builds each turn's request, so its turns take only their route's. A provider FX does not ship can be declared under `providers:` in `fx.yaml`: its key's variable and header, its base URL and the variable that moves it, its download hosts and its family; FX reads its variables from the environment or `.env` as its own, redacts its key, withholds both from Codex, Grok Build, the background service and the browser opener (node hosts inherit FX's environment, as before), and `doctor` lists it with its address and key state, while a name FX ships, a variable already read and FX's own, proxy, certificate, `PATH` and `HOME` variables are refused ([providers.md §1](spec/providers.md); [conformance](conformance/providers-declared/), `crates/grida-fx/tests/cli.rs`). A provider declared with `family: openai-compatible` is served for `structured.generate` and `agent.turn` by FX's own family adapters, which send only the Chat Completions members those need and the provider's `provider_options`, the key as declared (none for a keyless one), and read no cost, so each call is booked at its hold or cap; a route on it whose contract leaves out `adapter`, or names the family's adapter and leaves out `adapter_behavior`, is filled in from the planning project's declaration ([providers.md §2](spec/providers.md), [identity.md §7](spec/identity.md); replay tests on a made-up `acme`, `crates/grida-fx-providers/tests/openai_compatible.rs`; a live run with the network off reaching the adapter, `crates/grida-fx/tests/cli.rs`; the filled fingerprint, [conformance](conformance/providers-declared/)). A provider declared with `adapter: #` is served by the project's own adapter, written with `adapter(...)` in Python (`grida.fx`) or TypeScript (`@grida/fx`): planning and a dry run load it on the planning host of its language and refuse the command when they cannot (exit 2, `fx.yaml: providers..adapter: …`), and `doctor` loads it too and reports one it cannot load (`adapter # cannot load: `, exit 1); each route it serves gets `#` and its version, or `source:`, in its contract; `lock` pins a versioned one in `fx.lock` and planning reports its drift on the step; a live run, with the network off too, starts one adapter host per declaration on its first call, with its own declaration's variables and no other's, and shuts it down with the run. The engine checks the request and the key before the adapter sees a call, and the answer's shape and its capability after; it charges the cost the adapter reports, else the hold or cap; a lost host, an exception or an unreadable outcome is an unknown outcome (a send `Failed`, not retryable; a submit `Uncertain`; a collect `Unreachable`); and the adapter's `fetch` goes through the engine's transport, which attaches the key only to the base URL's origin, holds downloads to the declared hosts and honours the network switch, which an adapter's own HTTP client must check itself ([adapters.md §9](spec/adapters.md), [protocol.md §5.8, §6.8, §8](spec/protocol.md); synthetic adapters run live with the network off: an answer after a refused `fetch`, a long job submitted and collected, a refusal whose made-up key is redacted, drift, load refusals and a stand-in answering the adapters' routes, [conformance](conformance/adapter-py/) and [its TypeScript twin](conformance/adapter-ts/); the adapter host against a fake peer and a fake process, `crates/grida-fx-runtime/tests/adapter_host.rs`; both real hosts, `crates/grida-fx-runtime/tests/python_host.rs` and `typescript_host.rs`; the SDKs, `python/tests/test_adapter.py`, `python/tests/test_adapter_engine.py`, `js/fx/test/adapter.test.ts`, `js/fx/test/adapter-engine.test.ts`; planning and the lock, `crates/grida-fx-core/tests/planning.rs`; the filled contracts, [route vectors](spec/vectors/identity/routes.json); `crates/grida-fx/tests/cli.rs`). Deferred: an adapter in a package under `sources:` (a project module may import one), an `adapter.check` for stand-in answers, `fetch` methods beyond GET and POST, and offline replay through `fetch`. A declared provider with neither a family nor an adapter has no adapter: its routes plan and price, and a live call is `no_route`. A declaration is an `fx.yaml` setting, which neither SDK writes, so neither SDK has an API for it; adapters are authored with each SDK's `adapter(...)`. | A new model on GG, OpenRouter or fal runs from a project route alone (`extends`, a new model id, `provider_options`, a cap when it has no price); a provider that speaks the OpenAI chat wire runs from a declaration; any other provider runs from a declaration and a Python or TypeScript adapter. Plans price or cap every call, refusals name the missing piece, keys stay redacted, and every path has provider-free evidence. | For AR-19, [the export contract](spec/exports.md), [CLI conformance](conformance/portable-export/), exporter regression tests, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c2bbda63..63fa5957 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -33,12 +33,12 @@ plans, records and exports are byte-identical under each (the invoker, `GRIDA_FX unsupported platform `grida fx` explains this and exits 1; the rest of `grida` is unaffected. - **Your own engine.** `GRIDA_FX_BIN` points every launcher and both SDKs at a `grida-fx` binary, such as a source build or one built for another platform. -- **Python node bodies.** Python nodes, builders and the local built-ins (`image.resize`, - `files.copy`, `package`, …) need the Python package even when FX runs through npm, and in a - TypeScript project too; `GRIDA_FX_PYTHON` selects the interpreter. -- **TypeScript node bodies.** TypeScript (and JavaScript) nodes, builders and stand-in files run - in `@grida/fx`'s node host, ` <@grida/fx>/dist/host-entry.js`, on Node.js 24 or later - (type stripping and `module.registerHooks`; Bun and Deno cannot host). `` is +- **Python node bodies.** Python nodes, builders, adapters and the local built-ins + (`image.resize`, `files.copy`, `package`, …) need the Python package even when FX runs through + npm, and in a TypeScript project too; `GRIDA_FX_PYTHON` selects the interpreter. +- **TypeScript node bodies.** TypeScript (and JavaScript) nodes, builders, stand-in files and + adapters run in `@grida/fx`'s node host, ` <@grida/fx>/dist/host-entry.js`, on Node.js + 24 or later (type stripping and `module.registerHooks`; Bun and Deno cannot host). `` is `GRIDA_FX_NODE`, else `GRIDA_FX_SDK_NODE`, else `node` on `PATH`. `dist/host-entry.js` comes from the `@grida/fx` the workflow's project resolves (`node_modules/@grida/fx`, in the project or a folder above it), else from the one the planning project resolves, else it is @@ -147,6 +147,7 @@ bun --no-env-file run build:viewer # Contracts and runnable examples uv run --project python python tools/check_spec.py +uv run --project python python tools/make_models.py --check cargo build --locked -p grida-fx uv run --project python python conformance/run.py --command target/debug/grida-fx --js-sdk js/fx uv run --project python python tools/check_examples.py @@ -160,6 +161,17 @@ helper defaults to the release profile. Use the Python and JavaScript checks in AGENTS.md when changing either SDK. Changes to public schemas, identities, or protocol behavior update their specification and conformance evidence together. +The built-in route table is checked against a pinned release of +[Grida Models](https://github.com/gridaco/models) (spec/providers.md §3), whose +projection is `crates/grida-fx-providers/routes/models.json`. A new built-in route +gets its card in `CARDS` of `tools/make_models.py` and a new projection: run +`uv run --project python python tools/make_models.py --release ` with the +network, where `` is the tool's `RELEASE`, the pin. To move the pin, change +`RELEASE` and the release spec/providers.md §3 names (`--check` holds them +together), run the same command with the new tag, and fix what `cargo test -p +grida-fx-providers --test models` then finds, in the routes or in its exceptions +table. + ## Viewer development `web/viewer` builds the client embedded in the engine. Shared browser contracts diff --git a/Cargo.lock b/Cargo.lock index 4bfff25a..337aec99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -692,7 +692,7 @@ dependencies = [ [[package]] name = "grida-fx" -version = "0.6.1" +version = "0.7.0" dependencies = [ "chrono", "clap", @@ -714,7 +714,7 @@ dependencies = [ [[package]] name = "grida-fx-core" -version = "0.6.1" +version = "0.7.0" dependencies = [ "gif", "grida-fx-protocol", @@ -728,12 +728,13 @@ dependencies = [ "serde_json", "sha2 0.10.9", "tempfile", + "url", "yaml-rust2", ] [[package]] name = "grida-fx-protocol" -version = "0.6.1" +version = "0.7.0" dependencies = [ "indexmap", "jsonschema", @@ -744,7 +745,7 @@ dependencies = [ [[package]] name = "grida-fx-providers" -version = "0.6.1" +version = "0.7.0" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", @@ -766,7 +767,7 @@ dependencies = [ [[package]] name = "grida-fx-runtime" -version = "0.6.1" +version = "0.7.0" dependencies = [ "base64 0.22.1", "chrono", @@ -786,7 +787,7 @@ dependencies = [ [[package]] name = "grida-fx-viewer" -version = "0.6.1" +version = "0.7.0" dependencies = [ "axum", "chrono", diff --git a/Cargo.toml b/Cargo.toml index b7897b51..029f1857 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "3" [workspace.package] -version = "0.6.1" +version = "0.7.0" edition = "2024" rust-version = "1.89" repository = "https://github.com/gridaco/fx" @@ -35,7 +35,7 @@ chrono = { version = "0.4", default-features = false, features = ["clock", "std" base64 = "0.22" getrandom = "0.3" unicode-general-category = "1.1" -# rustls trusting the bundled roots and the platform's certificate store (spec/providers.md §2). +# rustls trusting the bundled roots and the platform's certificate store (spec/adapters.md §2). reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls-webpki-roots", "rustls-tls-native-roots", diff --git a/ISSUES.md b/ISSUES.md index 799d32a1..291abd68 100644 --- a/ISSUES.md +++ b/ISSUES.md @@ -6,7 +6,7 @@ Known problems that are not fixed yet. Each says what was seen, what is known, a **Status:** open. A design gap, not a bug. -An answer holds only its files and its `data`: "no request id, no usage, no provider URL" ([providers.md](spec/providers.md) §4.4, and the §11 change notes). That rule is right for the answer, since a call record is replayed to node bodies and must not depend on which provider made it. But nothing else keeps the provider's usage either: not the call record, and not the run's events. FX reads the cost it needs and drops the rest. +An answer holds only its files and its `data`: "no request id, no usage, no provider URL" ([adapters.md](spec/adapters.md) §3.4). That rule is right for the answer, since a call record is replayed to node bodies and must not depend on which provider made it. But nothing else keeps the provider's usage either: not the call record, and not the run's events. FX reads the cost it needs and drops the rest. So a bill cannot be explained after the fact: @@ -14,9 +14,9 @@ So a bill cannot be explained after the fact: - OpenAI reports token usage and no cost, so FX books an OpenAI image at its whole hold and keeps none of the tokens. Nothing then shows whether the hold covered the call. - No request or generation id is kept, so a call cannot be looked up at the provider afterwards. -The per-size image prices of [providers.md](spec/providers.md) §10 could only be measured from an earlier engine's records, which kept the usage block. Issue 2 is open because FX's own run kept nothing. +The per-size image prices of [providers.md](spec/providers.md) §3 could only be measured from an earlier engine's records, which kept the usage block. Issue 2 is open because FX's own run kept nothing. -**Proposal.** Keep answers as they are. Record what the provider reported on the run's `call` event: the token counts and the cost breakdown, as allowlisted numbers, and the provider's request id when it is a safe id ([providers.md](spec/providers.md) §8). None of it is a secret, none of it enters a call key, and a replay would carry no usage, since nothing was billed. Spec: [protocol.md](spec/protocol.md) (the `call` event), [providers.md](spec/providers.md) §4.4 and each provider's section of §9, and the events schema. +**Proposal.** Keep answers as they are. Record what the provider reported on the run's `call` event: the token counts and the cost breakdown, as allowlisted numbers, and the provider's request id when it is a safe id ([adapters.md](spec/adapters.md) §7). None of it is a secret, none of it enters a call key, and a replay would carry no usage, since nothing was billed. Spec: [protocol.md](spec/protocol.md) (the `call` event), [adapters.md](spec/adapters.md) §3.4 and each adapter's reference ([providers.md](spec/providers.md) §2), and the events schema. ## 2. OpenRouter billed twice the image output for one call @@ -39,7 +39,7 @@ The per-size image prices of [providers.md](spec/providers.md) §10 could only b **Open candidates:** -1. **`moderation: low`.** FX always passes it to OpenAI through `provider.options` ([providers.md](spec/providers.md) §9.2). The earlier calls that billed the single count did not send it. It is the only request difference found, and no mechanism is known for it to double the output. +1. **`moderation: low`.** FX always passes it to OpenAI through `provider.options` ([OpenRouter's adapter reference](crates/grida-fx-providers/src/openrouter/README.md)). The earlier calls that billed the single count did not send it. It is the only request difference found, and no mechanism is known for it to double the output. 2. **An unannounced change since late September**, at OpenRouter (counting the output twice) or at OpenAI (the model drawing twice as much at `max`). **Seen since.** On 2026-10-12, a 1024x1024 `max` generate (background `opaque`) on @@ -54,4 +54,4 @@ does not send `moderation: low`; it does not say which upstream GG used. - **Two 1024x1024 `max` calls on OpenRouter**, one with `moderation: low` and one without (about $0.85), separate candidate 1 from candidate 2. - **Once issue 1 is fixed**, any new call shows its breakdown in the run. -**What depends on it.** FX's OpenRouter image highs cover twice the output as a precaution. They are only holds, since OpenRouter reports its cost. If OpenAI itself now bills twice, the OpenAI and fal highs under-count by up to one output: a 1024x1024 generate would cost about $0.42 and be booked at $0.29 ([providers.md](spec/providers.md) §10, "An open risk"). +**What depends on it.** FX's OpenRouter image highs cover twice the output as a precaution. They are only holds, since OpenRouter reports its cost. If OpenAI itself now bills twice, the OpenAI and fal highs under-count by up to one output: a 1024x1024 generate would cost about $0.42 and be booked at $0.29 ([providers.md](spec/providers.md) §3, "An open risk"). diff --git a/README.md b/README.md index 2ab77a76..2b4680be 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ A workflow engine for images, audio, video, 3D and the code around them. A build system for generated assets. -- **Priced before it spends.** The whole run is priced up front, and a live run never crosses - its ceiling. +- **Priced before it spends.** The whole run is priced up front, and a live run reserves each call + against its ceiling before making it. - **Never pays twice.** Results and paid calls are cached by content, and an interrupted run picks up where it stopped. - **Judged, not just generated.** Steps can judge each other's output, draw again when rejected, @@ -42,7 +42,10 @@ step, and mix them freely: | **Provider keys** | `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `FAL_KEY`, `TRIPO_API_KEY`, `ELEVENLABS_API_KEY` | each provider's models, on its own account | | **Grida Gateway** | `GG_API_KEY` | text, images, video and 3D with one key and one bill | -[AI providers](docs/guide/13-providers.md) says what each one serves and where to get each key. +[AI providers](docs/guide/13-providers.md) says what each one serves and where to get each key. A +model FX does not list runs from a route of your own; a provider FX does not ship, from a +declaration in `fx.yaml` and, unless it speaks OpenAI's chat API, an adapter of your own in Python +or TypeScript ([Community providers](docs/guide/15-community-providers.md)). ## Plan. Run. Inspect. diff --git a/TODO.md b/TODO.md index 8663bf63..b3d85b74 100644 --- a/TODO.md +++ b/TODO.md @@ -369,9 +369,9 @@ implementation; installation alone does not authorize invocation. ## Codex-backed image generation for local workflows **Status: in review, 2026-10-12** ([gridaco/fx#46](https://github.com/gridaco/fx/pull/46)), as -the route `imagegen@codex` of `image.generate` -([spec/providers.md §9.6](spec/providers.md#96-codex)). Users signed into Codex with ChatGPT pick -it in `fx.yaml` and run with no provider key. `codex` bills the user's plan, not dollars: a live run whose +the route `imagegen@codex` of `image.generate` ([the Codex adapter's +reference](crates/grida-fx-providers/src/codex/README.md)). Users signed into Codex with ChatGPT +pick it in `fx.yaml` and run with no provider key. `codex` bills the user's plan, not dollars: a live run whose calls are all on it needs no `--max-usd`, runs under a ceiling of $0 and refuses any other provider. Codex is a *runtime* the engine owns: one `codex app-server` per invocation, started on the first Codex call, shared by the rest (a thread each) and shut down with the run; its @@ -431,7 +431,7 @@ Follow-ups: **Grok Build: in review, 2026-10-13** ([gridaco/fx#72](https://github.com/gridaco/fx/issues/72)), as the route `imagine@grok` of `image.generate` and `image.edit` -([spec/providers.md §9.8](spec/providers.md#98-grok-build)): Grok Build's own ACP server +([the Grok Build adapter's reference](crates/grida-fx-providers/src/grok/README.md)): Grok Build's own ACP server (`grok agent --no-leader stdio`) through FX's ACP client ([`acp`](crates/grida-fx-providers/src/acp/mod.rs)), a session per call whose agent offers only Grok Build's two image tools, billed to the user's subscription (`Billing::Plan`). Verified with fake Grok Build programs in tests and real runs on @@ -442,7 +442,8 @@ Grok Build 1.0.52. Follow-ups: client is provider-neutral. Still per runtime: the JSON-RPC connection (Codex's app-server client and [`acp`](crates/grida-fx-providers/src/acp/mod.rs) differ only in routing keys and request answers), the start-once runtime state, the version check (gridaco/fx#73 for Codex), - and spec/providers.md §9.6 and §9.8, which restate the same rules; share them spec first. + and the Codex and Grok Build adapters' references, which restate the same rules; share them spec + first. - **An aspect ratio.** Grok Build's tools take one (`1:1`, `16:9`, `9:16`, …); FX leaves it unset until `image.generate` has a shape setting (gridaco/fx#57). - **Retrying a failed image tool.** A passing network error between Grok Build and xAI fails the @@ -461,8 +462,8 @@ gridaco/fx#72. ## Grida Gateway follow-ups -**Status: in review, 2026-10-12.** Grida Gateway (`@gg`, [spec/providers.md -§9.7](spec/providers.md#97-grida-gateway)) serves text, agent turns, images, video, 3D models and +**Status: in review, 2026-10-12.** Grida Gateway (`@gg`, [its adapter +reference](crates/grida-fx-providers/src/gg/README.md)) serves text, agent turns, images, video, 3D models and rigs with one key, `GG_API_KEY`, and is what the docs recommend first. FX books a GG call at GG's settled charge, read from the response or its billing readback; one that has not settled within a short wait is booked at its whole hold. diff --git a/bun.lock b/bun.lock index 741e7b5b..796ebb7d 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ }, "web/fx-react": { "name": "@grida/fx-react", - "version": "0.6.1", + "version": "0.7.0", "dependencies": { "@grida/fx-web": "workspace:*", }, @@ -20,7 +20,7 @@ }, "web/fx-web": { "name": "@grida/fx-web", - "version": "0.6.1", + "version": "0.7.0", "dependencies": { "fflate": "0.8.3", "lucide": "1.52.0", diff --git a/conformance/README.md b/conformance/README.md index ede2b71c..2dc2e1eb 100644 --- a/conformance/README.md +++ b/conformance/README.md @@ -31,11 +31,12 @@ uv run --project python python conformance/run.py --command target/debug/grida-f path is made absolute from where you run the script, without following symbolic links (a virtual environment's `python` is one); a bare name such as `python3` is passed on unchanged. A stand-in case whose stand-in is Python (`run --stand-in .py#`) needs it - too, even without a `nodes/` folder: its stand-in is served by the same Python host. Only the + too, even without a `nodes/` folder: its stand-in is served by the same Python host, as are a + project adapter's (`adapter-py`) while planning and in a live run. Only the cases with no Python node module or stand-in, and that run no built-in local type (whose bodies are Python's, [protocol.md](../spec/protocol.md) §1), run without it. - **TypeScript node bodies.** The cases named `*-ts`, and `mixed-hosts`, use node types, - builders and stand-ins written in TypeScript, which the engine runs in the TypeScript node + builders, stand-ins and project adapters written in TypeScript, which the engine runs in the TypeScript node host of `@grida/fx` on Node 24 or later. Their `in/package.json` depends on `@grida/fx`, and the runner installs a built `@grida/fx` into each such project's `node_modules/@grida/fx` (its `package.json` and `dist/`, copied as `npm install` would place them), from `--js-sdk`, @@ -125,10 +126,11 @@ Each step is a mapping with these keys and no others: | `jsonl` | boolean | compare the saved text as JSON Lines, such as a run's `events.jsonl` (below) | | `yaml` | boolean | compare the saved text as YAML, by converting it to JSON (below) | | `absent` | list of paths | project paths that must hold no file once the step is done: nothing is there, or only folders with no file anywhere below them (an empty folder is no record) | +| `env` | mapping of name to string | environment variables for this step's command alone, beside the runner's (Environment, below): a made-up key for a provider the case declares, so the case can show it is redacted (`adapter-py`) | - A step needs `argv`, `read`, `files` or `absent`. In one step, `files` are written first, then `argv` runs, then `read` is read, then every `absent` path is checked. -- `status`, `mentions`, `never_mentions` and `stdin` need `argv`. `read`, `json`, `jsonl` and +- `status`, `mentions`, `never_mentions`, `stdin` and `env` need `argv`. `read`, `json`, `jsonl` and `yaml` need `save`, and `save` needs `argv` or `read`. `json`, `jsonl` and `yaml` exclude each other. - Paths in `files`, `read`, `absent` and `invalid_inputs` are POSIX and relative. They may not @@ -147,8 +149,8 @@ document the host of its file's language makes (`builder-ts`). | Command | What a case reads from it | |---|---| | `expand `, `identity `, `price ` | the expanded graph, each instance's identity, the price by phase, as JSON on stdout | -| `plan [--json]` | whether the workflow plans (a workflow found by id, or not found); with `--json`, the expanded graph as `expand` prints it | -| `run --run [--deliver =]… [--max-usd ]` | a local run in ``, or the continuation of the run that folder holds; its exit status and summary lines (`result ok spent $0.00`, `failed : `, `stopped `, `refused: …`); the files it writes under the folder; with `--deliver`, the output files copied to `` (`{key}` once per element) after the run. No case runs `--live`: one passes it only beside `--stand-in`, a usage error | +| `plan [--json]` | whether the workflow plans (a workflow found by id, or not found), and its text (phases, estimate, `note` lines); with `--json`, the expanded graph as `expand` prints it | +| `run --run [--deliver =]… [--max-usd ] [--live]` | a local run in ``, or the continuation of the run that folder holds; its exit status and summary lines (`result ok spent $0.00`, `failed : `, `stopped `, `refused: …`); the files it writes under the folder; with `--deliver`, the output files copied to `` (`{key}` once per element) after the run. Only `adapter-py` and `adapter-ts` run `--live`, where every paid call goes to a synthetic project adapter that makes no request and the network is off; another case passes it only beside `--stand-in`, a usage error | | `plan : --arg =…`, `run : --arg =… --run ` | a builder target: the builder is given each `--arg` as a string and returns the workflow, which plans and runs as a workflow file would; a function that does not return a workflow, one the file does not have, or a file no node host runs exits 2 | | `run … --stand-in #` (or `--stand-in=…`) for a `.py`, `.ts`, `.mts`, `.js` or `.mjs` file | a stand-in run: the paid calls the cache cannot answer go to `` in the case's ``, served in the project folder by the host of the file's language, offline and for nothing; its summary has the line `stand-in `, and it keeps its records in the stand-in store, `/stand-in/`. Its usage errors (with `--live` or `--yes-up-to`; a source of another form, such as a `.tsx` file, `--stand-in takes # for a .py, .ts, .mts, .js or .mjs file, or -`; a missing file; `--stand-in -` when standard input is not a socket) and a stand-in that cannot be loaded exit 2 | | `project ` | the run's record projected to its state, as JSON on stdout | @@ -157,7 +159,8 @@ document the host of its file's language makes (`builder-ts`). | `reroll ` | the next take of a step path, written to the workflow's takes file; one line saying so and a `next` line, on stdout: the command that repeats the folder's run, or advice when its record cannot repeat it | | `pick ` | a take chosen for a step path, with its result's digest, written to the takes file; one line saying so and a `next` line as `reroll` prints it, on stdout | | `takes list ` | the takes file's entries, one line each (` take [ ]`), on stdout | -| `lock [--check] [--same ]` | `fx.lock`, written or checked | +| `lock [--check] [--same #]…` | `fx.lock`, written or checked; `--same` confirms a versioned node type or project adapter whose source changed | +| `doctor` | whether the project file reads (exit 0, or 2 for a refused `fx.yaml`), the line of each provider `fx.yaml` declares, `provider missing` or `… needs no key`, and of each project adapter, `adapter # version : (request)`, checked with `mentions` only: its other lines describe the machine (its Python, Node, Codex and Grok Build) and the routes an engine ships | | `cache prune [--forget-user ] [--yes] [--json]` | what pruning the store would remove, or removed with `--yes`, per store (`removed results, calls (paid $), files: ; keeps …`), or the same as JSON (fx-cache-prune-v1); a refusal (`refused: : …`) exits 1 and removes nothing | | `runs list --json` | the project's runs, newest first, as JSON on stdout (fx-run-list-v1) | | `runs remove … [--yes] [--json]`, `runs remove --state … [--yes] [--json]` | what a removal would do, or did with `--yes`: one line per run (`would remove ()`, `removed ()`) and what the filters passed over, or the same as JSON (fx-run-removal-v1); a selection that is not a run exits 2 and removes nothing | @@ -231,16 +234,23 @@ Each step runs with a minimal environment, never the caller's: - `HOME`, a fresh empty folder for the case; - `NO_COLOR=1`, `LANG=C.UTF-8` and `PYTHONDONTWRITEBYTECODE=1`; - `GRIDA_FX_NETWORK=off` and `GRIDA_FX_DISABLE_DOTENV=1`, so no provider is reached and no `.env` - is read ([providers.md](../spec/providers.md) §2, §3); + is read ([adapters.md](../spec/adapters.md) §2; [providers.md](../spec/providers.md) §1); - `GRIDA_FX_PYTHON` and `GRIDA_FX_NODE`, each passed through when set (a path made absolute, above); - `RUSTUP_HOME` and `CARGO_HOME`, from the caller, else `~/.rustup` and `~/.cargo` when those folders exist, so that a `cargo run` command still finds its toolchain once `HOME` is replaced. -No provider key, `GRIDA_FX_TOOL_` override, FX cache location, `NODE_OPTIONS` or SDK +A step's `env` adds variables for that step alone, never over these: a name the runner sets, +`NODE_OPTIONS`, and any `GRIDA_FX_*` in any letter case, fail the case before any step runs. It holds made-up +values only, such as a declared provider's key whose redaction the case pins. + +No real provider key, `GRIDA_FX_TOOL_` override, FX cache location, `NODE_OPTIONS` or SDK variable (`GRIDA_FX_SDK_*`) can reach a case, and no case spends: a case that needs a paid call's -answer seeds the project's store with it (`cache-replay`), or answers it with a stand-in -(`stand-in-image`). +answer seeds the project's store with it (`cache-replay`), answers it with a stand-in +(`stand-in-image`), or declares a made-up provider whose synthetic project adapter answers it +and makes no request (`adapter-py`). Such a case commits its `in/fx.lock` with the adapter's +entry for its current version (run `lock` after changing it): `tools/check_spec.py` recomputes +each route fingerprint the case saves from that version (`tools/digest.py --current-lock`). ## Recording expected output @@ -263,12 +273,15 @@ alone. | Case | What it pins | |---|---| +| `adapter-py` | project adapters in Python (`providers..adapter`): a request adapter for a keyed provider and a long-job adapter for a keyless one, both synthetic and versioned, loaded while planning and named by `doctor`; each route's contract completed with its adapter's name and version; a live run with the network off answers one call (after a `fetch` the engine refuses), submits and collects a job, and refuses one with a reason whose made-up key is redacted, charged what each adapter reports; without the key, a result-cache hit asks nothing and a call is refused before its adapter; a stand-in answers the adapters' routes, starting no adapter host, its answers held to the same every-route check; an edit to an imported module is drift until `lock --same`; an undeclared attribute, a missing module and a family beside an adapter refused (exit 2) | +| `adapter-ts` | `adapter-py` with the adapters and the stand-in in TypeScript: the same steps, and bytes that differ only through the adapters' names | | `at-plan` | an `at: plan` step runs while planning, and a `for_each` over its output expands to keyed instances; its price | | `at-plan-run` | an `at: plan` step in a run: its keyed `for_each` of local steps runs, collected into one output; the `at: plan` step is not run again | | `builder-ts` | a TypeScript builder (`workflows/build.ts:build --arg count=3`) of TypeScript nodes: planned, run, and answered by the result cache in a second folder; `reroll` writes the takes file beside the builder; a function that returns no workflow, one the file lacks and a `.tsx` builder refused (exit 2) | | `cache-prune` | `cache prune` over a hand-written store: refused until the store's earlier users (`legacy`) are forgotten; a preview removes nothing; what the run names stays (a result, and a paid answer only its reservation names) with their files, the rest goes and its cost is reported; a second prune removes nothing | | `cache-replay` | a paid call answered offline from a seeded call record, without `--live`; a second run answered from the result cache | | `cache-replay-miss` | the same project without a store: a paid call without `--live` is refused (exit 1), naming the capability, the route and `--live` | +| `capped-route` | routes with a cap and no price (`price: {cap_usd: N}`): each call priced at $0 to its cap, an instance with a capped call marked `capped: true` in its price, one plan `note` per capped capability and route, and a cap on an entry that `extends` a priced route; a price that states no amount, a cap beside another member and a cap with more than 6 decimal places refused while the table is read (exit 2) | | `closure-errors-ts` | what the TypeScript node host refuses about a source closure: an import of a missing file and an import spelled unlike the file on disk, refused while planning with project-relative paths; a body that imports a project module by a path it computes when it runs fails the node (`node_failure`) | | `coalesce-field` | `a.b ?? c` falls back when an object has no field `b` (table rows without an optional field, in `if:` and in `with:`), while a missing field elsewhere is still refused | | `conditions-select` | `if:` on an input and on a judge's fact, `on_reject: continue`, and `fx/select@1` with `first_of` | @@ -290,10 +303,12 @@ alone. | `output-isolation-ts` | what a TypeScript body writes to standard output (`console.log`, `process.stdout.write`, children with inherited stdio, a child's child) reaches the engine's standard error, never its standard output or the protocol, and the run succeeds | | `phase` | a `for_each` over a step's output with `max:` makes a pending second phase, priced at its maximum | | `picture-limits` | what a step's own request asks of its route, refused while planning (exit 1): references on a route without `image_input`, an exact size and a transparent background it lacks, and more input pictures than an edit route's `max_input_pictures`, counted before they are made | +| `providers-declared` | providers declared in `fx.yaml` (`providers:`): a workflow on a declared provider's image route plans, and expands to the same graph without the declarations; its `structured.generate` route, declared without a contract, is completed with the `openai-compatible` family's adapter, and keeps its bare contract's fingerprint once the provider is no longer declared; `doctor` lists each with its base URL's host and its key's state; a name FX ships, a variable a built-in reads, a variable FX or its programs read for themselves (in any letter case) and a plain-http default base URL off this machine refused while `fx.yaml` is read (exit 2) | | `refusals` | six planning problems in one plan (an assertion, a feature under its old name, a missing feature, `independent_of`, an unknown route, an unbounded repeat), exit 1 | | `refusals-ts` | `.tsx`, `.cts` and `.cjs` modules and a `.js` module Node loads as CommonJS refused while planning (exit 1); a NaN fact, a `Map` in a JSON output and a keyed output returned as a plain object fail their nodes | | `repeat-keyed` | a keyed `for_each` over an input list, collected into one step | | `resource-missing` | a declared resource that is not in the project is a planning problem (exit 1), never a crash | +| `route-extends` | a route that `extends` another takes its price, features and contract, and restates what differs (a contract member, a price); `extends` is not hashed; a base no table read so far holds, or one on another provider, is refused (exit 2) | | `run-cache-hit` | the same plan run into a second folder: every step is a result-cache hit, with the same output bytes | | `run-deliver` | `--deliver` of a one-file and a keyed output (keys made safe paths), left alone when unchanged; an undeclared output refused before the run (exit 2) | | `run-failures` | `ctx.fail` keeps its node facts (in the run's events), an exception is a `node_error`, a reader of a failed step is skipped as blocked; the run goes on, lists its failures in the expansion's order and exits 1 | diff --git a/conformance/adapter-py/case.yaml b/conformance/adapter-py/case.yaml new file mode 100644 index 00000000..375f533a --- /dev/null +++ b/conformance/adapter-py/case.yaml @@ -0,0 +1,137 @@ +# Project adapters in Python (adapters.md §9): fx.yaml declares `acme`, keyed by ACME_API_KEY and +# served by adapters/acme.py#acme, a request adapter, and the keyless `acme-jobs`, served by +# adapters/acme.py#acme_jobs, a long-job adapter of the same module. Both are synthetic and made +# up: they answer from what they are asked and make no request, and the network is off. Both are +# versioned, and the committed fx.lock holds them, so `lock --check` passes. +# - doctor names each adapter, its version and each capability's kind. +# - Planning loads both adapters on the planning host and needs no adapter host: plan.txt shows +# the two capped routes; expand.json's fingerprints are of each route's contract completed +# with its adapter's name and version (`{"adapter": "adapters/acme.py#acme", +# "adapter_behavior": 1}`), which check_spec (h) recomputes from fx.yaml and fx.lock. +# - runs/one, live with a made-up key (the step's env): `name` is answered at once after a fetch +# the engine refuses before it leaves (the network is off), its adapter host given its own +# key; `slowly` is submitted, then collected, by a host given no key of another declaration; +# each route's contract reaches the adapter completed; `refused` is refused with a reason that +# holds the key, which reaches no output and no record ([redacted]). Each answer is charged +# the cost its adapter reports ($0.01 and $0.02), the refusal nothing: the run spends $0.03 +# and exits 1. +# - runs/two, without the key: `name` and `slowly` are result-cache hits that ask no adapter, +# and `refused` is refused before its adapter is asked, since its declaration's key is not +# set; nothing is spent. +# - runs/stand-in: in/stand_in.py answers the adapters' routes, and no adapter host starts (a +# run without --live builds no adapter). Its answers get the check every structured.generate +# route gets, as the adapters' do: `name`'s is stored, `slowly`'s fails the schema, and +# `refused` is declined. +# - An edit to a module the adapters import moves their source closure, so the plan reports the +# drift on each step their routes serve (exit 1) and `lock --check` refuses it, until +# `lock --same` confirms both adapters. +# - A declaration that names an attribute the module does not declare with adapter(), a missing +# module, and a family beside an adapter, refuse the command (exit 2). +steps: +- argv: [lock, --check] +- argv: [doctor] + mentions: + - "provider acme api.acme.test ACME_API_KEY missing" + - "provider acme-jobs jobs.acme.test needs no key" + - "adapter acme adapters/acme.py#acme version 1: structured.generate (request)" + - "adapter acme-jobs adapters/acme.py#acme_jobs version 1: structured.generate (job)" +- argv: [plan, case, --routes, routes.yaml, --max-usd, "1"] + save: plan.txt +- argv: [expand, case, --routes, routes.yaml] + save: expand.json + json: true +- argv: [run, case, --routes, routes.yaml, --run, runs/one, --live, --max-usd, "1"] + env: + ACME_API_KEY: acme-made-up-key-5e1f + status: 1 + mentions: + - "result failed spent $0.03" + - "failed refused#1: structured.generate on text-a@acme was refused: no colours for [redacted]" + never_mentions: + - acme-made-up-key-5e1f + - "{project}" +- read: runs/one/outputs/name.json + save: name.json + json: true +- read: runs/one/outputs/slowly.json + save: slowly.json + json: true +- argv: [project, runs/one] + save: project-one.json + json: true + never_mentions: + - acme-made-up-key-5e1f +- argv: [run, case, --routes, routes.yaml, --run, runs/two, --live, --max-usd, "1"] + status: 1 + mentions: + - "result failed spent $0.00" + - "failed refused#1: structured.generate on text-a@acme was refused: ACME_API_KEY is not set" +- argv: [project, runs/two] + save: project-two.json + json: true +- argv: [run, case, --routes, routes.yaml, --run, runs/stand-in, --stand-in, "stand_in.py#answer"] + status: 1 + mentions: + - "stand-in stand_in.py#answer" + - "result failed spent $0.00" + - "failed slowly#1: structured.generate on text-b@acme-jobs failed: name: 3 is not of type \"string\"" + - "failed refused#1: structured.generate on text-a@acme is a paid call the stand-in declined" +- read: runs/stand-in/outputs/name.json + save: stand-in-name.json + json: true +- files: + adapters/colours.py: | + """The colours acme's synthetic adapter answers with, edited.""" + + ASKED = "teal" + WAITED_FOR = "slate" + argv: [expand, case, --routes, routes.yaml] + status: 1 + mentions: + - "adapters/acme.py#acme: its source changed but version 1 did not; bump the version, or confirm no change in behaviour with " + - "lock --same adapters/acme.py#acme" + - "adapters/acme.py#acme_jobs: its source changed but version 1 did not" +- argv: [lock, --check] + status: 1 + mentions: + - "changed adapters/acme.py#acme: its source changed but version 1 did not; bump it, or confirm with --same adapters/acme.py#acme" + - "changed adapters/acme.py#acme_jobs: " +- argv: [lock, --same, "adapters/acme.py#acme", --same, "adapters/acme.py#acme_jobs"] + mentions: + - "fx.lock: 0 versioned node types, 2 versioned adapters" +- argv: [lock, --check] +- argv: [expand, case, --routes, routes.yaml] +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + base_url: { default: "https://api.acme.test/v1" } + adapter: adapters/acme.py#painter + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.acme.adapter: adapters/acme.py: painter is not declared with adapter()" +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + base_url: { default: "https://api.acme.test/v1" } + adapter: adapters/missing.py#acme + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.acme.adapter: no file adapters/missing.py" +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + base_url: { default: "https://api.acme.test/v1" } + family: openai-compatible + adapter: adapters/acme.py#acme + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.acme: a provider has a family or an adapter, not both" diff --git a/conformance/adapter-py/expected/expand.json b/conformance/adapter-py/expected/expand.json new file mode 100644 index 00000000..0e879171 --- /dev/null +++ b/conformance/adapter-py/expected/expand.json @@ -0,0 +1,182 @@ +{ + "estimate": { + "ceiling_usd": null, + "high_usd": 0.15, + "low_usd": 0 + }, + "instances": [ + { + "bindings": [], + "id": "name#1", + "identity": "1d296e2f20861f87e86690b72600bd0e3e18431cfc7b0a604375f5b6034fee62", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "name", + "phase": 1, + "price": { + "capped": true, + "high_usd": 0.05, + "low_usd": 0 + }, + "reads": [], + "routes": { + "structured.generate": { + "fingerprint": "5c339a6c0382d6033c2e33d5316e4cffd196709ecc4daf890bc2a9656a92ff1d", + "route": "text-a@acme" + } + }, + "state": "planned", + "step": "name", + "take": [ + 1 + ], + "type": "fx/structured.generate@1.1", + "uses": "fx/structured.generate@1", + "view": false, + "waiting_on": [], + "with": { + "matte": "#ffffff", + "prompt": "Name a colour.", + "schema": { + "file": "6fc6118ab871d0157e370adee71bbb8e62d83f0dd1c5ac36caf118f48bba36c9" + }, + "vars": {} + } + }, + { + "bindings": [], + "id": "slowly#1", + "identity": "36976fcae786246b663da15abee8ebc1a622a5b82e8fceeed6d0ec9e5d5d769e", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "slowly", + "phase": 1, + "price": { + "capped": true, + "high_usd": 0.05, + "low_usd": 0 + }, + "reads": [], + "routes": { + "structured.generate": { + "fingerprint": "477a568e4bb037c13b7e2e45ad6896808cd6392730eec2bd247e05f6cd8328ef", + "route": "text-b@acme-jobs" + } + }, + "state": "planned", + "step": "slowly", + "take": [ + 1 + ], + "type": "fx/structured.generate@1.1", + "uses": "fx/structured.generate@1", + "view": false, + "waiting_on": [], + "with": { + "matte": "#ffffff", + "prompt": "Name a colour slowly.", + "schema": { + "file": "6fc6118ab871d0157e370adee71bbb8e62d83f0dd1c5ac36caf118f48bba36c9" + }, + "vars": {} + } + }, + { + "bindings": [], + "id": "refused#1", + "identity": "7dc3377bf4824028f10a77af8e5f6a19c1718c100e202c2aeb2846c1895ca042", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "refused", + "phase": 1, + "price": { + "capped": true, + "high_usd": 0.05, + "low_usd": 0 + }, + "reads": [], + "routes": { + "structured.generate": { + "fingerprint": "5c339a6c0382d6033c2e33d5316e4cffd196709ecc4daf890bc2a9656a92ff1d", + "route": "text-a@acme" + } + }, + "state": "planned", + "step": "refused", + "take": [ + 1 + ], + "type": "fx/structured.generate@1.1", + "uses": "fx/structured.generate@1", + "view": false, + "waiting_on": [], + "with": { + "matte": "#ffffff", + "prompt": "refuse", + "schema": { + "file": "6fc6118ab871d0157e370adee71bbb8e62d83f0dd1c5ac36caf118f48bba36c9" + }, + "vars": {} + } + } + ], + "kind": "fx-graph-v1", + "pending": [], + "problems": [], + "scopes": [], + "types": { + "fx/structured.generate@1": { + "identity": "fx/structured.generate@1.1", + "ports": { + "inputs": { + "context": "file[]?", + "schema": "json" + }, + "outputs": { + "json": "json" + }, + "params": { + "matte": { + "default": "#ffffff", + "type": "string" + }, + "max_tokens": { + "type": "integer", + "x-fx-optional": true + }, + "prompt": { + "type": "string", + "x-fx-template": true + }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, + "system": { + "type": "string", + "x-fx-optional": true, + "x-fx-template": true + }, + "vars": { + "default": {}, + "type": "object" + } + } + } + } + }, + "workflow": { + "file": "workflows/case.yaml", + "id": "case", + "title": "Colours from a project adapter" + } +} diff --git a/conformance/adapter-py/expected/name.json b/conformance/adapter-py/expected/name.json new file mode 100644 index 00000000..8180d162 --- /dev/null +++ b/conformance/adapter-py/expected/name.json @@ -0,0 +1,13 @@ +{ + "attempt": 1, + "contract": { + "adapter": "adapters/acme.py#acme", + "adapter_behavior": 1 + }, + "fetched": [ + "refused", + "the network is off (GRIDA_FX_NETWORK=off)" + ], + "key_given": true, + "name": "teal" +} diff --git a/conformance/adapter-py/expected/plan.txt b/conformance/adapter-py/expected/plan.txt new file mode 100644 index 00000000..2529efb5 --- /dev/null +++ b/conformance/adapter-py/expected/plan.txt @@ -0,0 +1,6 @@ +case · 1 phase +phase 1 3 steps 3 provider calls $0.00 – $0.15 +cached 0 of 3 known steps +estimate $0.00 – $0.15 ceiling $1.00 +note structured.generate on text-a@acme has no price: each call holds its cap, $0.05, and is charged what the provider reports, else the cap +note structured.generate on text-b@acme-jobs has no price: each call holds its cap, $0.05, and is charged what the provider reports, else the cap diff --git a/conformance/adapter-py/expected/project-one.json b/conformance/adapter-py/expected/project-one.json new file mode 100644 index 00000000..61ef5e24 --- /dev/null +++ b/conformance/adapter-py/expected/project-one.json @@ -0,0 +1,76 @@ +{ + "instances": { + "name#1": { + "cache": "miss", + "facts": { + "cost_usd": 0.01 + }, + "path": "name", + "state": "succeeded" + }, + "refused#1": { + "error": "structured.generate on text-a@acme was refused: no colours for [redacted]", + "path": "refused", + "state": "failed" + }, + "slowly#1": { + "cache": "miss", + "facts": { + "cost_usd": 0.02 + }, + "path": "slowly", + "state": "succeeded" + } + }, + "run": { + "run_finished": { + "charged_usd": 0.03, + "event": "run_finished", + "failed": [ + "refused#1" + ], + "incomplete": false, + "ok": false, + "outputs": { + "name": { + "file": { + "digest": "bb8de2796263152381bbb20b5333d911d9010f1153d44fc9f3de95e108e069c5", + "kind": "json", + "name": "name/json", + "size": 212 + } + }, + "slowly": { + "file": { + "digest": "054dc0eabbc5e02d5ca56bcc02d36539b9b94673c5f657f8bc005f78dd33ccca", + "kind": "json", + "name": "slowly/json", + "size": 151 + } + } + }, + "plan": "73669d008c409a7b6392e74bc382cd3df88f4bc466bf5c2cf501ba8719c75cd2", + "stopped": null + }, + "run_started": { + "ceiling_usd": 1, + "charged_usd": 0, + "estimate": { + "high_usd": 0.15, + "low_usd": 0 + }, + "event": "run_started", + "plan": "73669d008c409a7b6392e74bc382cd3df88f4bc466bf5c2cf501ba8719c75cd2", + "repeat": { + "live": true, + "max_usd": 1, + "routes": [ + "routes.yaml" + ], + "workflow": "case" + }, + "resumed": false, + "workflow": "case" + } + } +} diff --git a/conformance/adapter-py/expected/project-two.json b/conformance/adapter-py/expected/project-two.json new file mode 100644 index 00000000..1f4647ab --- /dev/null +++ b/conformance/adapter-py/expected/project-two.json @@ -0,0 +1,76 @@ +{ + "instances": { + "name#1": { + "cache": "hit", + "facts": { + "cost_usd": 0.01 + }, + "path": "name", + "state": "succeeded" + }, + "refused#1": { + "error": "structured.generate on text-a@acme was refused: ACME_API_KEY is not set", + "path": "refused", + "state": "failed" + }, + "slowly#1": { + "cache": "hit", + "facts": { + "cost_usd": 0.02 + }, + "path": "slowly", + "state": "succeeded" + } + }, + "run": { + "run_finished": { + "charged_usd": 0, + "event": "run_finished", + "failed": [ + "refused#1" + ], + "incomplete": false, + "ok": false, + "outputs": { + "name": { + "file": { + "digest": "bb8de2796263152381bbb20b5333d911d9010f1153d44fc9f3de95e108e069c5", + "kind": "json", + "name": "name/json", + "size": 212 + } + }, + "slowly": { + "file": { + "digest": "054dc0eabbc5e02d5ca56bcc02d36539b9b94673c5f657f8bc005f78dd33ccca", + "kind": "json", + "name": "slowly/json", + "size": 151 + } + } + }, + "plan": "73669d008c409a7b6392e74bc382cd3df88f4bc466bf5c2cf501ba8719c75cd2", + "stopped": null + }, + "run_started": { + "ceiling_usd": 1, + "charged_usd": 0, + "estimate": { + "high_usd": 0.05, + "low_usd": 0 + }, + "event": "run_started", + "plan": "73669d008c409a7b6392e74bc382cd3df88f4bc466bf5c2cf501ba8719c75cd2", + "repeat": { + "live": true, + "max_usd": 1, + "routes": [ + "routes.yaml" + ], + "workflow": "case" + }, + "resumed": false, + "workflow": "case" + } + } +} diff --git a/conformance/adapter-py/expected/slowly.json b/conformance/adapter-py/expected/slowly.json new file mode 100644 index 00000000..5248ab0b --- /dev/null +++ b/conformance/adapter-py/expected/slowly.json @@ -0,0 +1,9 @@ +{ + "contract": { + "adapter": "adapters/acme.py#acme_jobs", + "adapter_behavior": 1 + }, + "job": "655a845c0937", + "key_given": false, + "name": "slate" +} diff --git a/conformance/adapter-py/expected/stand-in-name.json b/conformance/adapter-py/expected/stand-in-name.json new file mode 100644 index 00000000..226f1a48 --- /dev/null +++ b/conformance/adapter-py/expected/stand-in-name.json @@ -0,0 +1,3 @@ +{ + "name": "stand-in teal" +} diff --git a/conformance/adapter-py/in/adapters/acme.py b/conformance/adapter-py/in/adapters/acme.py new file mode 100644 index 00000000..5326ea49 --- /dev/null +++ b/conformance/adapter-py/in/adapters/acme.py @@ -0,0 +1,46 @@ +"""A synthetic adapter for the made-up provider acme: it answers from what it is asked and makes +no request. Its one fetch is refused before it leaves, since a conformance step runs with the +network off. `acme` answers at once; `acme_jobs` submits a long job and collects it.""" + +import os + +from grida.fx import Accepted, Answered, FetchError, Refused, adapter + +from adapters import colours + + +async def send(call): + if call.request["prompt"] == "refuse": + # The key in a reason reaches no record: the engine redacts it. + return Refused("no colours for " + os.environ["ACME_API_KEY"]) + try: + await call.fetch(call.base_url + "/chat", method="POST", json={}, credential=True) + fetched = None + except FetchError as error: + fetched = [error.phase, error.reason] + answer = { + "name": colours.ASKED, + "fetched": fetched, + "contract": call.route.contract, + "attempt": call.attempt, + "key_given": "ACME_API_KEY" in os.environ, + } + return Answered.json(answer, cost_usd=0.01, usage={"input_tokens": 5, "output_tokens": 3}) + + +def submit(call): + return Accepted({"job": call.key[:12]}) + + +async def collect(call): + answer = { + "name": colours.WAITED_FOR, + "job": call.handle["job"], + "contract": call.route.contract, + "key_given": "ACME_API_KEY" in os.environ, + } + return Answered.json(answer, cost_usd=0.02) + + +acme = adapter(requests=["structured.generate"], send=send, version=1) +acme_jobs = adapter(jobs=["structured.generate"], submit=submit, collect=collect, version=1) diff --git a/conformance/adapter-py/in/adapters/colours.py b/conformance/adapter-py/in/adapters/colours.py new file mode 100644 index 00000000..ad17e7cf --- /dev/null +++ b/conformance/adapter-py/in/adapters/colours.py @@ -0,0 +1,4 @@ +"""The colours acme's synthetic adapter answers with: part of its source closure.""" + +ASKED = "teal" +WAITED_FOR = "slate" diff --git a/conformance/adapter-py/in/fx.lock b/conformance/adapter-py/in/fx.lock new file mode 100644 index 00000000..3fdf22d0 --- /dev/null +++ b/conformance/adapter-py/in/fx.lock @@ -0,0 +1,6 @@ +# fx.lock: the source behind each versioned node type; commit it +fx: lock/v1 +nodes: {} +adapters: + adapters/acme.py#acme@1: f3330785f5399aad6701904d1ee000774592c8ca611c35ed58e274ec13db312a + adapters/acme.py#acme_jobs@1: f3330785f5399aad6701904d1ee000774592c8ca611c35ed58e274ec13db312a diff --git a/conformance/adapter-py/in/fx.yaml b/conformance/adapter-py/in/fx.yaml new file mode 100644 index 00000000..c66f804e --- /dev/null +++ b/conformance/adapter-py/in/fx.yaml @@ -0,0 +1,11 @@ +fx: project/v1 +routes: + structured.generate: text-a@acme +providers: + acme: + credential: { variable: ACME_API_KEY } + base_url: { default: "https://api.acme.test/v1" } + adapter: adapters/acme.py#acme + acme-jobs: + base_url: { default: "https://jobs.acme.test/v1" } + adapter: adapters/acme.py#acme_jobs diff --git a/conformance/adapter-py/in/routes.yaml b/conformance/adapter-py/in/routes.yaml new file mode 100644 index 00000000..754c28a3 --- /dev/null +++ b/conformance/adapter-py/in/routes.yaml @@ -0,0 +1,4 @@ +fx: routes/v1 +routes: + - { capability: structured.generate, route: text-a@acme, price: { cap_usd: 0.05 } } + - { capability: structured.generate, route: text-b@acme-jobs, price: { cap_usd: 0.05 } } diff --git a/conformance/adapter-py/in/schemas/colour.json b/conformance/adapter-py/in/schemas/colour.json new file mode 100644 index 00000000..add7a4d5 --- /dev/null +++ b/conformance/adapter-py/in/schemas/colour.json @@ -0,0 +1 @@ +{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} diff --git a/conformance/adapter-py/in/stand_in.py b/conformance/adapter-py/in/stand_in.py new file mode 100644 index 00000000..7952cd56 --- /dev/null +++ b/conformance/adapter-py/in/stand_in.py @@ -0,0 +1,14 @@ +"""The stand-in of the adapter-py case: it answers acme's routes in place of the project adapters, +which a run without --live never starts. Its answers get the check every route of their +capability gets, as the adapters' do: structured.generate's holds the json to the schema.""" + +from grida.fx import DECLINE, Answer + + +def answer(call): + if call.request["prompt"] == "refuse": + return DECLINE + if call.route.id == "text-b@acme-jobs": + # A colour's name is a string: the schema refuses this one. + return Answer.json({"name": 3}) + return Answer.json({"name": "stand-in teal"}) diff --git a/conformance/adapter-py/in/workflows/case.yaml b/conformance/adapter-py/in/workflows/case.yaml new file mode 100644 index 00000000..80af7fca --- /dev/null +++ b/conformance/adapter-py/in/workflows/case.yaml @@ -0,0 +1,25 @@ +fx: workflow/v1 +id: case +title: Colours from a project adapter +steps: + name: + uses: fx/structured.generate@1 + route: text-a@acme + with: + prompt: Name a colour. + schema: ./schemas/colour.json + slowly: + uses: fx/structured.generate@1 + route: text-b@acme-jobs + with: + prompt: Name a colour slowly. + schema: ./schemas/colour.json + refused: + uses: fx/structured.generate@1 + route: text-a@acme + with: + prompt: refuse + schema: ./schemas/colour.json +outputs: + name: ${{ steps.name.outputs.json }} + slowly: ${{ steps.slowly.outputs.json }} diff --git a/conformance/adapter-ts/case.yaml b/conformance/adapter-ts/case.yaml new file mode 100644 index 00000000..a9e29153 --- /dev/null +++ b/conformance/adapter-ts/case.yaml @@ -0,0 +1,114 @@ +# adapter-py in TypeScript (adapters.md §9; protocol.md §8.2): the same declarations, served by +# adapters/acme.ts#acme, a request adapter, and adapters/acme.ts#acmeJobs, a long-job adapter of +# the same module, in the TypeScript node host of @grida/fx, and in/stand_in.ts as the stand-in. +# Every step pins what adapter-py's does. The bytes differ from adapter-py's only through the adapters' names, which are part of +# each route's contract: the fingerprints, so the plan's digest and the call keys (`slowly`'s +# job), and the outputs that hold the contract. plan.txt is adapter-py's, byte for byte. +steps: +- argv: [lock, --check] +- argv: [doctor] + mentions: + - "provider acme api.acme.test ACME_API_KEY missing" + - "provider acme-jobs jobs.acme.test needs no key" + - "adapter acme adapters/acme.ts#acme version 1: structured.generate (request)" + - "adapter acme-jobs adapters/acme.ts#acmeJobs version 1: structured.generate (job)" +- argv: [plan, case, --routes, routes.yaml, --max-usd, "1"] + save: plan.txt +- argv: [expand, case, --routes, routes.yaml] + save: expand.json + json: true +- argv: [run, case, --routes, routes.yaml, --run, runs/one, --live, --max-usd, "1"] + env: + ACME_API_KEY: acme-made-up-key-8a2c + status: 1 + mentions: + - "result failed spent $0.03" + - "failed refused#1: structured.generate on text-a@acme was refused: no colours for [redacted]" + never_mentions: + - acme-made-up-key-8a2c + - "{project}" +- read: runs/one/outputs/name.json + save: name.json + json: true +- read: runs/one/outputs/slowly.json + save: slowly.json + json: true +- argv: [project, runs/one] + save: project-one.json + json: true + never_mentions: + - acme-made-up-key-8a2c +- argv: [run, case, --routes, routes.yaml, --run, runs/two, --live, --max-usd, "1"] + status: 1 + mentions: + - "result failed spent $0.00" + - "failed refused#1: structured.generate on text-a@acme was refused: ACME_API_KEY is not set" +- argv: [project, runs/two] + save: project-two.json + json: true +- argv: [run, case, --routes, routes.yaml, --run, runs/stand-in, --stand-in, "stand_in.ts#answer"] + status: 1 + mentions: + - "stand-in stand_in.ts#answer" + - "result failed spent $0.00" + - "failed slowly#1: structured.generate on text-b@acme-jobs failed: name: 3 is not of type \"string\"" + - "failed refused#1: structured.generate on text-a@acme is a paid call the stand-in declined" +- read: runs/stand-in/outputs/name.json + save: stand-in-name.json + json: true +- files: + adapters/colours.ts: | + // The colours acme's synthetic adapter answers with, edited. + + export const ASKED = "teal"; + export const WAITED_FOR = "slate"; + argv: [expand, case, --routes, routes.yaml] + status: 1 + mentions: + - "adapters/acme.ts#acme: its source changed but version 1 did not; bump the version, or confirm no change in behaviour with " + - "lock --same adapters/acme.ts#acme" + - "adapters/acme.ts#acmeJobs: its source changed but version 1 did not" +- argv: [lock, --check] + status: 1 + mentions: + - "changed adapters/acme.ts#acme: its source changed but version 1 did not; bump it, or confirm with --same adapters/acme.ts#acme" + - "changed adapters/acme.ts#acmeJobs: " +- argv: [lock, --same, "adapters/acme.ts#acme", --same, "adapters/acme.ts#acmeJobs"] + mentions: + - "fx.lock: 0 versioned node types, 2 versioned adapters" +- argv: [lock, --check] +- argv: [expand, case, --routes, routes.yaml] +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + base_url: { default: "https://api.acme.test/v1" } + adapter: adapters/acme.ts#painter + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.acme.adapter: adapters/acme.ts: painter is not an export declared with adapter()" +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + base_url: { default: "https://api.acme.test/v1" } + adapter: adapters/missing.ts#acme + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.acme.adapter: no file adapters/missing.ts" +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + base_url: { default: "https://api.acme.test/v1" } + family: openai-compatible + adapter: adapters/acme.ts#acme + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.acme: a provider has a family or an adapter, not both" diff --git a/conformance/adapter-ts/expected/expand.json b/conformance/adapter-ts/expected/expand.json new file mode 100644 index 00000000..101d5c92 --- /dev/null +++ b/conformance/adapter-ts/expected/expand.json @@ -0,0 +1,182 @@ +{ + "estimate": { + "ceiling_usd": null, + "high_usd": 0.15, + "low_usd": 0 + }, + "instances": [ + { + "bindings": [], + "id": "name#1", + "identity": "e754959af8dc6f47af52133b3545314a4c17d9828331f4808494bbf08b85e76c", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "name", + "phase": 1, + "price": { + "capped": true, + "high_usd": 0.05, + "low_usd": 0 + }, + "reads": [], + "routes": { + "structured.generate": { + "fingerprint": "dda86166a6df4b7f3ef667fa7c011ab8f7a443000fb84010663bf39b93728a42", + "route": "text-a@acme" + } + }, + "state": "planned", + "step": "name", + "take": [ + 1 + ], + "type": "fx/structured.generate@1.1", + "uses": "fx/structured.generate@1", + "view": false, + "waiting_on": [], + "with": { + "matte": "#ffffff", + "prompt": "Name a colour.", + "schema": { + "file": "6fc6118ab871d0157e370adee71bbb8e62d83f0dd1c5ac36caf118f48bba36c9" + }, + "vars": {} + } + }, + { + "bindings": [], + "id": "slowly#1", + "identity": "63a933f3b71895562683fbc646a0b6f991a467516b025e03503dddbaf2dd030f", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "slowly", + "phase": 1, + "price": { + "capped": true, + "high_usd": 0.05, + "low_usd": 0 + }, + "reads": [], + "routes": { + "structured.generate": { + "fingerprint": "757e5ca54c9c14acf69d11f2d624de93a087458831b4394e063c92cb87373209", + "route": "text-b@acme-jobs" + } + }, + "state": "planned", + "step": "slowly", + "take": [ + 1 + ], + "type": "fx/structured.generate@1.1", + "uses": "fx/structured.generate@1", + "view": false, + "waiting_on": [], + "with": { + "matte": "#ffffff", + "prompt": "Name a colour slowly.", + "schema": { + "file": "6fc6118ab871d0157e370adee71bbb8e62d83f0dd1c5ac36caf118f48bba36c9" + }, + "vars": {} + } + }, + { + "bindings": [], + "id": "refused#1", + "identity": "0683154d256dec1abc94b09330478d6ad2b7250e6979bf51ec2c8fd98bbf2be3", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "refused", + "phase": 1, + "price": { + "capped": true, + "high_usd": 0.05, + "low_usd": 0 + }, + "reads": [], + "routes": { + "structured.generate": { + "fingerprint": "dda86166a6df4b7f3ef667fa7c011ab8f7a443000fb84010663bf39b93728a42", + "route": "text-a@acme" + } + }, + "state": "planned", + "step": "refused", + "take": [ + 1 + ], + "type": "fx/structured.generate@1.1", + "uses": "fx/structured.generate@1", + "view": false, + "waiting_on": [], + "with": { + "matte": "#ffffff", + "prompt": "refuse", + "schema": { + "file": "6fc6118ab871d0157e370adee71bbb8e62d83f0dd1c5ac36caf118f48bba36c9" + }, + "vars": {} + } + } + ], + "kind": "fx-graph-v1", + "pending": [], + "problems": [], + "scopes": [], + "types": { + "fx/structured.generate@1": { + "identity": "fx/structured.generate@1.1", + "ports": { + "inputs": { + "context": "file[]?", + "schema": "json" + }, + "outputs": { + "json": "json" + }, + "params": { + "matte": { + "default": "#ffffff", + "type": "string" + }, + "max_tokens": { + "type": "integer", + "x-fx-optional": true + }, + "prompt": { + "type": "string", + "x-fx-template": true + }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, + "system": { + "type": "string", + "x-fx-optional": true, + "x-fx-template": true + }, + "vars": { + "default": {}, + "type": "object" + } + } + } + } + }, + "workflow": { + "file": "workflows/case.yaml", + "id": "case", + "title": "Colours from a project adapter" + } +} diff --git a/conformance/adapter-ts/expected/name.json b/conformance/adapter-ts/expected/name.json new file mode 100644 index 00000000..ab5182ec --- /dev/null +++ b/conformance/adapter-ts/expected/name.json @@ -0,0 +1,13 @@ +{ + "attempt": 1, + "contract": { + "adapter": "adapters/acme.ts#acme", + "adapter_behavior": 1 + }, + "fetched": [ + "refused", + "the network is off (GRIDA_FX_NETWORK=off)" + ], + "key_given": true, + "name": "teal" +} diff --git a/conformance/adapter-ts/expected/plan.txt b/conformance/adapter-ts/expected/plan.txt new file mode 100644 index 00000000..2529efb5 --- /dev/null +++ b/conformance/adapter-ts/expected/plan.txt @@ -0,0 +1,6 @@ +case · 1 phase +phase 1 3 steps 3 provider calls $0.00 – $0.15 +cached 0 of 3 known steps +estimate $0.00 – $0.15 ceiling $1.00 +note structured.generate on text-a@acme has no price: each call holds its cap, $0.05, and is charged what the provider reports, else the cap +note structured.generate on text-b@acme-jobs has no price: each call holds its cap, $0.05, and is charged what the provider reports, else the cap diff --git a/conformance/adapter-ts/expected/project-one.json b/conformance/adapter-ts/expected/project-one.json new file mode 100644 index 00000000..4d2e3d83 --- /dev/null +++ b/conformance/adapter-ts/expected/project-one.json @@ -0,0 +1,76 @@ +{ + "instances": { + "name#1": { + "cache": "miss", + "facts": { + "cost_usd": 0.01 + }, + "path": "name", + "state": "succeeded" + }, + "refused#1": { + "error": "structured.generate on text-a@acme was refused: no colours for [redacted]", + "path": "refused", + "state": "failed" + }, + "slowly#1": { + "cache": "miss", + "facts": { + "cost_usd": 0.02 + }, + "path": "slowly", + "state": "succeeded" + } + }, + "run": { + "run_finished": { + "charged_usd": 0.03, + "event": "run_finished", + "failed": [ + "refused#1" + ], + "incomplete": false, + "ok": false, + "outputs": { + "name": { + "file": { + "digest": "1dd26913e134546692bf31f8c6b02be08050911c31b2bd23e19c11b891e92a67", + "kind": "json", + "name": "name/json", + "size": 212 + } + }, + "slowly": { + "file": { + "digest": "550c7de4cf0a72bb15e8ae1cc81737adf3f21a423b9827b2e74227bea628a1ec", + "kind": "json", + "name": "slowly/json", + "size": 150 + } + } + }, + "plan": "6579807a5020a0ec7e626520b0d91e77ac535bc95db6245d28c89e9a3b62f868", + "stopped": null + }, + "run_started": { + "ceiling_usd": 1, + "charged_usd": 0, + "estimate": { + "high_usd": 0.15, + "low_usd": 0 + }, + "event": "run_started", + "plan": "6579807a5020a0ec7e626520b0d91e77ac535bc95db6245d28c89e9a3b62f868", + "repeat": { + "live": true, + "max_usd": 1, + "routes": [ + "routes.yaml" + ], + "workflow": "case" + }, + "resumed": false, + "workflow": "case" + } + } +} diff --git a/conformance/adapter-ts/expected/project-two.json b/conformance/adapter-ts/expected/project-two.json new file mode 100644 index 00000000..1d9bb193 --- /dev/null +++ b/conformance/adapter-ts/expected/project-two.json @@ -0,0 +1,76 @@ +{ + "instances": { + "name#1": { + "cache": "hit", + "facts": { + "cost_usd": 0.01 + }, + "path": "name", + "state": "succeeded" + }, + "refused#1": { + "error": "structured.generate on text-a@acme was refused: ACME_API_KEY is not set", + "path": "refused", + "state": "failed" + }, + "slowly#1": { + "cache": "hit", + "facts": { + "cost_usd": 0.02 + }, + "path": "slowly", + "state": "succeeded" + } + }, + "run": { + "run_finished": { + "charged_usd": 0, + "event": "run_finished", + "failed": [ + "refused#1" + ], + "incomplete": false, + "ok": false, + "outputs": { + "name": { + "file": { + "digest": "1dd26913e134546692bf31f8c6b02be08050911c31b2bd23e19c11b891e92a67", + "kind": "json", + "name": "name/json", + "size": 212 + } + }, + "slowly": { + "file": { + "digest": "550c7de4cf0a72bb15e8ae1cc81737adf3f21a423b9827b2e74227bea628a1ec", + "kind": "json", + "name": "slowly/json", + "size": 150 + } + } + }, + "plan": "6579807a5020a0ec7e626520b0d91e77ac535bc95db6245d28c89e9a3b62f868", + "stopped": null + }, + "run_started": { + "ceiling_usd": 1, + "charged_usd": 0, + "estimate": { + "high_usd": 0.05, + "low_usd": 0 + }, + "event": "run_started", + "plan": "6579807a5020a0ec7e626520b0d91e77ac535bc95db6245d28c89e9a3b62f868", + "repeat": { + "live": true, + "max_usd": 1, + "routes": [ + "routes.yaml" + ], + "workflow": "case" + }, + "resumed": false, + "workflow": "case" + } + } +} diff --git a/conformance/adapter-ts/expected/slowly.json b/conformance/adapter-ts/expected/slowly.json new file mode 100644 index 00000000..de95bfee --- /dev/null +++ b/conformance/adapter-ts/expected/slowly.json @@ -0,0 +1,9 @@ +{ + "contract": { + "adapter": "adapters/acme.ts#acmeJobs", + "adapter_behavior": 1 + }, + "job": "14bbd0d54bfe", + "key_given": false, + "name": "slate" +} diff --git a/conformance/adapter-ts/expected/stand-in-name.json b/conformance/adapter-ts/expected/stand-in-name.json new file mode 100644 index 00000000..226f1a48 --- /dev/null +++ b/conformance/adapter-ts/expected/stand-in-name.json @@ -0,0 +1,3 @@ +{ + "name": "stand-in teal" +} diff --git a/conformance/adapter-ts/in/adapters/acme.ts b/conformance/adapter-ts/in/adapters/acme.ts new file mode 100644 index 00000000..6c04b9cb --- /dev/null +++ b/conformance/adapter-ts/in/adapters/acme.ts @@ -0,0 +1,49 @@ +// A synthetic adapter for the made-up provider acme: it answers from what it is asked and makes +// no request. Its one fetch is refused before it leaves, since a conformance step runs with the +// network off. `acme` answers at once; `acmeJobs` submits a long job and collects it. + +import { Answered, adapter, FetchError } from "@grida/fx"; +import { ASKED, WAITED_FOR } from "./colours.ts"; + +export const acme = adapter({ + requests: ["structured.generate"], + send: async (call) => { + if (call.request.prompt === "refuse") { + // The key in a reason reaches no record: the engine redacts it. + return { outcome: "refused", reason: `no colours for ${process.env.ACME_API_KEY}` }; + } + let fetched: string[] | null = null; + try { + await call.fetch(`${call.baseUrl}/chat`, { method: "POST", json: {}, credential: true }); + } catch (error) { + if (!(error instanceof FetchError)) { + throw error; + } + fetched = [error.phase, error.reason]; + } + const answer = { + name: ASKED, + fetched, + contract: call.route.contract, + attempt: call.attempt, + key_given: "ACME_API_KEY" in process.env, + }; + return Answered.json(answer, { costUsd: 0.01, usage: { inputTokens: 5, outputTokens: 3 } }); + }, + version: 1, +}); + +export const acmeJobs = adapter({ + jobs: ["structured.generate"], + submit: (call) => ({ outcome: "accepted", handle: { job: call.key.slice(0, 12) } }), + collect: async (call) => { + const answer = { + name: WAITED_FOR, + job: call.handle.job ?? null, + contract: call.route.contract, + key_given: "ACME_API_KEY" in process.env, + }; + return Answered.json(answer, { costUsd: 0.02 }); + }, + version: 1, +}); diff --git a/conformance/adapter-ts/in/adapters/colours.ts b/conformance/adapter-ts/in/adapters/colours.ts new file mode 100644 index 00000000..05f18e33 --- /dev/null +++ b/conformance/adapter-ts/in/adapters/colours.ts @@ -0,0 +1,4 @@ +// The colours acme's synthetic adapter answers with: part of its source closure. + +export const ASKED = "teal"; +export const WAITED_FOR = "slate"; diff --git a/conformance/adapter-ts/in/fx.lock b/conformance/adapter-ts/in/fx.lock new file mode 100644 index 00000000..5c8a9bd8 --- /dev/null +++ b/conformance/adapter-ts/in/fx.lock @@ -0,0 +1,6 @@ +# fx.lock: the source behind each versioned node type; commit it +fx: lock/v1 +nodes: {} +adapters: + adapters/acme.ts#acme@1: 229b75a0733808e739d4c78d23d7706592da2e4fd16ebfab91072118b7b16f1f + adapters/acme.ts#acmeJobs@1: 229b75a0733808e739d4c78d23d7706592da2e4fd16ebfab91072118b7b16f1f diff --git a/conformance/adapter-ts/in/fx.yaml b/conformance/adapter-ts/in/fx.yaml new file mode 100644 index 00000000..ae053254 --- /dev/null +++ b/conformance/adapter-ts/in/fx.yaml @@ -0,0 +1,11 @@ +fx: project/v1 +routes: + structured.generate: text-a@acme +providers: + acme: + credential: { variable: ACME_API_KEY } + base_url: { default: "https://api.acme.test/v1" } + adapter: adapters/acme.ts#acme + acme-jobs: + base_url: { default: "https://jobs.acme.test/v1" } + adapter: adapters/acme.ts#acmeJobs diff --git a/conformance/adapter-ts/in/package.json b/conformance/adapter-ts/in/package.json new file mode 100644 index 00000000..ade0b862 --- /dev/null +++ b/conformance/adapter-ts/in/package.json @@ -0,0 +1 @@ +{"private": true, "type": "module", "dependencies": {"@grida/fx": "*"}} diff --git a/conformance/adapter-ts/in/routes.yaml b/conformance/adapter-ts/in/routes.yaml new file mode 100644 index 00000000..754c28a3 --- /dev/null +++ b/conformance/adapter-ts/in/routes.yaml @@ -0,0 +1,4 @@ +fx: routes/v1 +routes: + - { capability: structured.generate, route: text-a@acme, price: { cap_usd: 0.05 } } + - { capability: structured.generate, route: text-b@acme-jobs, price: { cap_usd: 0.05 } } diff --git a/conformance/adapter-ts/in/schemas/colour.json b/conformance/adapter-ts/in/schemas/colour.json new file mode 100644 index 00000000..add7a4d5 --- /dev/null +++ b/conformance/adapter-ts/in/schemas/colour.json @@ -0,0 +1 @@ +{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} diff --git a/conformance/adapter-ts/in/stand_in.ts b/conformance/adapter-ts/in/stand_in.ts new file mode 100644 index 00000000..c56ae617 --- /dev/null +++ b/conformance/adapter-ts/in/stand_in.ts @@ -0,0 +1,17 @@ +// The stand-in of the adapter-ts case, adapter-py's stand_in.py in TypeScript: it answers acme's +// routes in place of the project adapters, which a run without --live never starts. Its answers +// get the check every route of their capability gets, as the adapters' do: structured.generate's +// holds the json to the schema. + +import { Answer, DECLINE, type Decline, type StandInCall } from "@grida/fx"; + +export function answer(call: StandInCall): Answer | Decline { + if (call.request.prompt === "refuse") { + return DECLINE; + } + if (call.route.id === "text-b@acme-jobs") { + // A colour's name is a string: the schema refuses this one. + return Answer.json({ name: 3 }); + } + return Answer.json({ name: "stand-in teal" }); +} diff --git a/conformance/adapter-ts/in/workflows/case.yaml b/conformance/adapter-ts/in/workflows/case.yaml new file mode 100644 index 00000000..80af7fca --- /dev/null +++ b/conformance/adapter-ts/in/workflows/case.yaml @@ -0,0 +1,25 @@ +fx: workflow/v1 +id: case +title: Colours from a project adapter +steps: + name: + uses: fx/structured.generate@1 + route: text-a@acme + with: + prompt: Name a colour. + schema: ./schemas/colour.json + slowly: + uses: fx/structured.generate@1 + route: text-b@acme-jobs + with: + prompt: Name a colour slowly. + schema: ./schemas/colour.json + refused: + uses: fx/structured.generate@1 + route: text-a@acme + with: + prompt: refuse + schema: ./schemas/colour.json +outputs: + name: ${{ steps.name.outputs.json }} + slowly: ${{ steps.slowly.outputs.json }} diff --git a/conformance/at-plan/expected/expand.json b/conformance/at-plan/expected/expand.json index f4185c75..08bb6e3f 100644 --- a/conformance/at-plan/expected/expand.json +++ b/conformance/at-plan/expected/expand.json @@ -236,6 +236,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/capped-route/case.yaml b/conformance/capped-route/case.yaml new file mode 100644 index 00000000..c1a6416b --- /dev/null +++ b/conformance/capped-route/case.yaml @@ -0,0 +1,41 @@ +# Routes with a cap and no price (fx-routes-v1 `cap_usd`): each call is priced at $0 to its cap, +# an instance with a capped call carries `capped: true` in its price, and the plan notes each +# capped capability and route once (plan.txt). img-y@acme extends a priced route and restates its +# price as a cap. A price that states no amount, a cap beside another member, and a cap with more +# than 6 decimal places are refused while the table is read (exit 2). +steps: +- argv: [plan, case, --routes, routes.yaml] + save: plan.txt +- argv: [expand, case, --routes, routes.yaml] + save: expand.json + json: true +- argv: [price, case, --routes, routes.yaml] + save: price.json + json: true +- files: + empty.yaml: | + fx: routes/v1 + routes: + - { capability: image.generate, route: img-z@acme, price: { unit: call } } + argv: [expand, case, --routes, routes.yaml, --routes, empty.yaml] + status: 2 + mentions: + - "empty.yaml: routes.0.price: a price states an amount (usd, low_usd, high_usd) or a cap (cap_usd)" +- files: + beside.yaml: | + fx: routes/v1 + routes: + - { capability: image.generate, route: img-z@acme, price: { cap_usd: 1.5, high_usd: 2 } } + argv: [expand, case, --routes, routes.yaml, --routes, beside.yaml] + status: 2 + mentions: + - "beside.yaml: routes.0.price" +- files: + places.yaml: | + fx: routes/v1 + routes: + - { capability: image.generate, route: img-z@acme, price: { cap_usd: 0.0000001 } } + argv: [expand, case, --routes, routes.yaml, --routes, places.yaml] + status: 2 + mentions: + - "places.yaml: routes.0.price.cap_usd: 1e-7 has more than 6 decimal places" diff --git a/conformance/capped-route/expected/expand.json b/conformance/capped-route/expected/expand.json new file mode 100644 index 00000000..5cef2138 --- /dev/null +++ b/conformance/capped-route/expected/expand.json @@ -0,0 +1,308 @@ +{ + "estimate": { + "ceiling_usd": null, + "high_usd": 6.04, + "low_usd": 0.01 + }, + "instances": [ + { + "bindings": [], + "id": "priced#1", + "identity": "bbb4a14e9e0ec26d0f9904231b52a0d30a391fa09acaa4bd5c3508be0991fc14", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "priced", + "phase": 1, + "price": { + "high_usd": 0.04, + "low_usd": 0.01 + }, + "reads": [], + "routes": { + "image.generate": { + "fingerprint": "4d41b81c56215efdd18574eab8e2b704a8ecc86af08d569f55cd29973c4e7ed4", + "route": "img-a@acme" + } + }, + "state": "planned", + "step": "priced", + "take": [ + 1 + ], + "type": "fx/image.generate@1.1", + "uses": "fx/image.generate@1", + "view": false, + "waiting_on": [], + "with": { + "background": "auto", + "prompt": "a kite", + "vars": {} + } + }, + { + "bindings": [], + "id": "capped#1", + "identity": "40f9fd1848e676544aa98464127f29c1d07744819aa56351b4d8750948f1dd96", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "capped", + "phase": 1, + "price": { + "capped": true, + "high_usd": 1.5, + "low_usd": 0 + }, + "reads": [], + "routes": { + "image.generate": { + "fingerprint": "b5be815f2f02884e599402e0f35426c86e63b5a17221d6c8504d6caa69d9fa85", + "route": "img-x@acme" + } + }, + "state": "planned", + "step": "capped", + "take": [ + 1 + ], + "type": "fx/image.generate@1.1", + "uses": "fx/image.generate@1", + "view": false, + "waiting_on": [], + "with": { + "background": "auto", + "prompt": "a kite", + "vars": {} + } + }, + { + "bindings": [], + "id": "again#1", + "identity": "79da998b4b74303d1fda9168a3b22ebc0c6833c7db9e824ece59b8b887455b04", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "again", + "phase": 1, + "price": { + "capped": true, + "high_usd": 1.5, + "low_usd": 0 + }, + "reads": [], + "routes": { + "image.generate": { + "fingerprint": "b5be815f2f02884e599402e0f35426c86e63b5a17221d6c8504d6caa69d9fa85", + "route": "img-x@acme" + } + }, + "state": "planned", + "step": "again", + "take": [ + 1 + ], + "type": "fx/image.generate@1.1", + "uses": "fx/image.generate@1", + "view": false, + "waiting_on": [], + "with": { + "background": "auto", + "prompt": "a lantern", + "vars": {} + } + }, + { + "bindings": [ + { + "source": "capped#1", + "source_kind": "output", + "source_port": "image", + "target_port": "image" + } + ], + "id": "edit#1", + "identity": null, + "interface_bindings": [ + { + "source": "capped#1", + "source_kind": "output", + "source_port": "image", + "target_port": "image" + } + ], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "edit", + "phase": 1, + "price": { + "capped": true, + "high_usd": 0.75, + "low_usd": 0 + }, + "reads": [ + "capped#1" + ], + "routes": { + "image.edit": { + "fingerprint": "879887a7a34c25c73cb101310721ed2013f42814da139f8c058d22bdd7e2ba3b", + "route": "img-x@acme" + } + }, + "state": "planned", + "step": "edit", + "take": [ + 1 + ], + "type": "fx/image.edit@1.1", + "uses": "fx/image.edit@1", + "view": false, + "waiting_on": [ + "capped#1" + ], + "with": { + "background": "auto", + "image": { + "pending": [ + "capped#1" + ] + }, + "prompt": "the kite at dusk", + "vars": {} + } + }, + { + "bindings": [], + "id": "extended#1", + "identity": "8b9f236a58366bd51b3ee394826f37071f4f017a0831a9c69efbbc0eaf99261f", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "extended", + "phase": 1, + "price": { + "capped": true, + "high_usd": 2.25, + "low_usd": 0 + }, + "reads": [], + "routes": { + "image.generate": { + "fingerprint": "f2104e4876773edd2c2d0652a5483f22725df9de7a3a986da4ebfc711daeed80", + "route": "img-y@acme" + } + }, + "state": "planned", + "step": "extended", + "take": [ + 1 + ], + "type": "fx/image.generate@1.1", + "uses": "fx/image.generate@1", + "view": false, + "waiting_on": [], + "with": { + "background": "auto", + "prompt": "a kite", + "vars": {} + } + } + ], + "kind": "fx-graph-v1", + "pending": [], + "problems": [], + "scopes": [], + "types": { + "fx/image.edit@1": { + "identity": "fx/image.edit@1.1", + "ports": { + "inputs": { + "image": "image", + "mask": "image?", + "references": "image[]?" + }, + "outputs": { + "image": "image/png" + }, + "params": { + "background": { + "default": "auto", + "enum": [ + "opaque", + "transparent", + "auto" + ] + }, + "prompt": { + "type": "string", + "x-fx-template": true + }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, + "size": { + "type": "string", + "x-fx-optional": true + }, + "vars": { + "default": {}, + "type": "object" + } + } + } + }, + "fx/image.generate@1": { + "identity": "fx/image.generate@1.1", + "ports": { + "inputs": { + "references": "image[]?" + }, + "outputs": { + "image": "image/png" + }, + "params": { + "background": { + "default": "auto", + "enum": [ + "opaque", + "transparent", + "auto" + ] + }, + "prompt": { + "type": "string", + "x-fx-template": true + }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, + "size": { + "type": "string", + "x-fx-optional": true + }, + "vars": { + "default": {}, + "type": "object" + } + } + } + } + }, + "workflow": { + "file": "workflows/case.yaml", + "id": "case", + "title": "Capped routes" + } +} diff --git a/conformance/capped-route/expected/plan.txt b/conformance/capped-route/expected/plan.txt new file mode 100644 index 00000000..14f5b1ce --- /dev/null +++ b/conformance/capped-route/expected/plan.txt @@ -0,0 +1,7 @@ +case · 1 phase +phase 1 5 steps 5 provider calls $0.01 – $6.04 +cached 0 of 4 known steps +estimate $0.01 – $6.04 +note image.edit on img-x@acme has no price: each call holds its cap, $0.75, and is charged what the provider reports, else the cap +note image.generate on img-x@acme has no price: each call holds its cap, $1.50, and is charged what the provider reports, else the cap +note image.generate on img-y@acme has no price: each call holds its cap, $2.25, and is charged what the provider reports, else the cap diff --git a/conformance/capped-route/expected/price.json b/conformance/capped-route/expected/price.json new file mode 100644 index 00000000..b60a2ea7 --- /dev/null +++ b/conformance/capped-route/expected/price.json @@ -0,0 +1,20 @@ +{ + "ceiling_usd": null, + "estimate": { + "high_usd": 6.04, + "low_usd": 0.01 + }, + "phases": [ + { + "calls": [ + 5, + 5 + ], + "high_usd": 6.04, + "low_usd": 0.01, + "phase": 1, + "steps": 5, + "then": [] + } + ] +} diff --git a/conformance/capped-route/in/fx.yaml b/conformance/capped-route/in/fx.yaml new file mode 100644 index 00000000..ef9216d8 --- /dev/null +++ b/conformance/capped-route/in/fx.yaml @@ -0,0 +1,3 @@ +fx: project/v1 +routes: + image.generate: img-a@acme diff --git a/conformance/capped-route/in/routes.yaml b/conformance/capped-route/in/routes.yaml new file mode 100644 index 00000000..70b26711 --- /dev/null +++ b/conformance/capped-route/in/routes.yaml @@ -0,0 +1,6 @@ +fx: routes/v1 +routes: + - { capability: image.generate, route: img-a@acme, price: { low_usd: 0.01, high_usd: 0.04 } } + - { capability: image.generate, route: img-x@acme, price: { cap_usd: 1.5 } } + - { capability: image.edit, route: img-x@acme, price: { cap_usd: 0.75 } } + - { capability: image.generate, route: img-y@acme, extends: img-a@acme, price: { cap_usd: 2.25 } } diff --git a/conformance/capped-route/in/workflows/case.yaml b/conformance/capped-route/in/workflows/case.yaml new file mode 100644 index 00000000..75ba3528 --- /dev/null +++ b/conformance/capped-route/in/workflows/case.yaml @@ -0,0 +1,23 @@ +fx: workflow/v1 +id: case +title: Capped routes +steps: + priced: + uses: fx/image.generate@1 + with: { prompt: a kite } + capped: + uses: fx/image.generate@1 + route: img-x@acme + with: { prompt: a kite } + again: + uses: fx/image.generate@1 + route: img-x@acme + with: { prompt: a lantern } + edit: + uses: fx/image.edit@1 + route: img-x@acme + with: { image: "${{ steps.capped.outputs.image }}", prompt: the kite at dusk } + extended: + uses: fx/image.generate@1 + route: img-y@acme + with: { prompt: a kite } diff --git a/conformance/coalesce-field/expected/expand.json b/conformance/coalesce-field/expected/expand.json index 944496b3..920d28af 100644 --- a/conformance/coalesce-field/expected/expand.json +++ b/conformance/coalesce-field/expected/expand.json @@ -144,6 +144,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/conditions-select/expected/expand.json b/conformance/conditions-select/expected/expand.json index 12ecf975..fedb3d6e 100644 --- a/conformance/conditions-select/expected/expand.json +++ b/conformance/conditions-select/expected/expand.json @@ -232,6 +232,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/facts/expected/expand.json b/conformance/facts/expected/expand.json index 1e889bb6..80c9d037 100644 --- a/conformance/facts/expected/expand.json +++ b/conformance/facts/expected/expand.json @@ -129,6 +129,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/group-regenerate/expected/expand.json b/conformance/group-regenerate/expected/expand.json index d43ac10e..25142460 100644 --- a/conformance/group-regenerate/expected/expand.json +++ b/conformance/group-regenerate/expected/expand.json @@ -478,6 +478,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/judge-regenerate/expected/expand.json b/conformance/judge-regenerate/expected/expand.json index a7f19fe3..f7bf9ac1 100644 --- a/conformance/judge-regenerate/expected/expand.json +++ b/conformance/judge-regenerate/expected/expand.json @@ -404,6 +404,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/linear/expected/expand.json b/conformance/linear/expected/expand.json index d6190dec..687f7b9a 100644 --- a/conformance/linear/expected/expand.json +++ b/conformance/linear/expected/expand.json @@ -142,6 +142,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/phase/expected/expand.json b/conformance/phase/expected/expand.json index 2f6252a7..25b838c4 100644 --- a/conformance/phase/expected/expand.json +++ b/conformance/phase/expected/expand.json @@ -94,6 +94,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/picture-limits/expected/expand.json b/conformance/picture-limits/expected/expand.json index 825c8083..04a84a54 100644 --- a/conformance/picture-limits/expected/expand.json +++ b/conformance/picture-limits/expected/expand.json @@ -303,6 +303,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -336,6 +340,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/providers-declared/case.yaml b/conformance/providers-declared/case.yaml new file mode 100644 index 00000000..39d2ec9c --- /dev/null +++ b/conformance/providers-declared/case.yaml @@ -0,0 +1,101 @@ +# A project that declares providers (providers.md §1): `acme`, with its key's variable and +# header, a base URL its own variable moves, download hosts and a family, and `local`, a keyless +# server on this machine. A declaration is configuration, not identity: the workflow on an acme +# image route plans, and expands to the same graph with and without the declarations (expand.json, +# saved twice). Only a family reaches a fingerprint (identity.md §7): the chat workflow's +# structured.generate route on acme, declared without a contract, is completed with the +# openai-compatible family's adapter (expand-chat.json), and keeps its bare contract's fingerprint +# once acme is no longer declared. doctor lists each declared provider after the built-in keys, +# with its base URL's host and where its key comes from. A declaration that takes a name FX ships, +# a variable a built-in reads, one FX or the programs it starts read for themselves (in any letter +# case), or a plain-http default base URL off this machine is refused while fx.yaml is read +# (exit 2). +steps: +- argv: [plan, case, --routes, routes.yaml] + save: plan.txt +- argv: [expand, case, --routes, routes.yaml] + save: expand.json + json: true +- argv: [expand, chat, --routes, routes.yaml] + save: expand-chat.json + json: true +- argv: [doctor] + mentions: + - "provider acme api.acme.test ACME_API_KEY missing" + - "provider local 127.0.0.1:8080 needs no key" +- files: + fx.yaml: | + fx: project/v1 + routes: + image.generate: img-a@acme + argv: [expand, case, --routes, routes.yaml] + save: expand.json + json: true +- argv: [expand, chat, --routes, routes.yaml] + mentions: + - b84a7f3e9926f76c4c628e5bfb6cf513d87f0e570b13a65c954113c5c171a055 + never_mentions: + - c349af10e0a600f63fef6013c88dc88926acf949c5bcade06c74bc4563217099 +- files: + fx.yaml: | + fx: project/v1 + providers: + fal: + base_url: { default: "https://fal.example.test" } + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.fal: fal is a provider FX ships" +- files: + fx.yaml: | + fx: project/v1 + providers: + codex: + base_url: { default: "https://codex.example.test" } + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.codex: codex is a provider FX ships" +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + credential: { variable: ACME_API_KEY } + base_url: { variable: OPENAI_BASE_URL, default: "https://api.acme.test/v1" } + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.acme.base_url.variable: OPENAI_BASE_URL is already read for openai" +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + credential: { variable: https_proxy } + base_url: { default: "https://api.acme.test/v1" } + argv: [doctor] + status: 2 + mentions: + - "fx.yaml: providers.acme.credential.variable: https_proxy is reserved for FX and the programs it starts" +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + credential: { variable: GRIDA_FX_NETWORK } + base_url: { default: "https://api.acme.test/v1" } + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.acme.credential.variable: GRIDA_FX_NETWORK is reserved for FX and the programs it starts" +- files: + fx.yaml: | + fx: project/v1 + providers: + acme: + base_url: { default: "http://api.acme.test/v1" } + argv: [plan, case, --routes, routes.yaml] + status: 2 + mentions: + - "fx.yaml: providers.acme.base_url.default: acme base_url must use HTTPS unless it targets a loopback host" diff --git a/conformance/providers-declared/expected/expand-chat.json b/conformance/providers-declared/expected/expand-chat.json new file mode 100644 index 00000000..43233d94 --- /dev/null +++ b/conformance/providers-declared/expected/expand-chat.json @@ -0,0 +1,100 @@ +{ + "estimate": { + "ceiling_usd": null, + "high_usd": 0.05, + "low_usd": 0 + }, + "instances": [ + { + "bindings": [], + "id": "name#1", + "identity": "0701df3a5fdcd0d89a36bf7b353acc87d724b2d321e4ef87b478dd2bddd78950", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "name", + "phase": 1, + "price": { + "capped": true, + "high_usd": 0.05, + "low_usd": 0 + }, + "reads": [], + "routes": { + "structured.generate": { + "fingerprint": "c349af10e0a600f63fef6013c88dc88926acf949c5bcade06c74bc4563217099", + "route": "text-a@acme" + } + }, + "state": "planned", + "step": "name", + "take": [ + 1 + ], + "type": "fx/structured.generate@1.1", + "uses": "fx/structured.generate@1", + "view": false, + "waiting_on": [], + "with": { + "matte": "#ffffff", + "prompt": "Name a colour.", + "schema": { + "file": "6fc6118ab871d0157e370adee71bbb8e62d83f0dd1c5ac36caf118f48bba36c9" + }, + "vars": {} + } + } + ], + "kind": "fx-graph-v1", + "pending": [], + "problems": [], + "scopes": [], + "types": { + "fx/structured.generate@1": { + "identity": "fx/structured.generate@1.1", + "ports": { + "inputs": { + "context": "file[]?", + "schema": "json" + }, + "outputs": { + "json": "json" + }, + "params": { + "matte": { + "default": "#ffffff", + "type": "string" + }, + "max_tokens": { + "type": "integer", + "x-fx-optional": true + }, + "prompt": { + "type": "string", + "x-fx-template": true + }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, + "system": { + "type": "string", + "x-fx-optional": true, + "x-fx-template": true + }, + "vars": { + "default": {}, + "type": "object" + } + } + } + } + }, + "workflow": { + "file": "workflows/chat.yaml", + "id": "chat", + "title": "A declared family's chat route" + } +} diff --git a/conformance/providers-declared/expected/expand.json b/conformance/providers-declared/expected/expand.json new file mode 100644 index 00000000..85c25ac8 --- /dev/null +++ b/conformance/providers-declared/expected/expand.json @@ -0,0 +1,94 @@ +{ + "estimate": { + "ceiling_usd": null, + "high_usd": 0.04, + "low_usd": 0.01 + }, + "instances": [ + { + "bindings": [], + "id": "draw#1", + "identity": "bbb4a14e9e0ec26d0f9904231b52a0d30a391fa09acaa4bd5c3508be0991fc14", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "draw", + "phase": 1, + "price": { + "high_usd": 0.04, + "low_usd": 0.01 + }, + "reads": [], + "routes": { + "image.generate": { + "fingerprint": "4d41b81c56215efdd18574eab8e2b704a8ecc86af08d569f55cd29973c4e7ed4", + "route": "img-a@acme" + } + }, + "state": "planned", + "step": "draw", + "take": [ + 1 + ], + "type": "fx/image.generate@1.1", + "uses": "fx/image.generate@1", + "view": false, + "waiting_on": [], + "with": { + "background": "auto", + "prompt": "a kite", + "vars": {} + } + } + ], + "kind": "fx-graph-v1", + "pending": [], + "problems": [], + "scopes": [], + "types": { + "fx/image.generate@1": { + "identity": "fx/image.generate@1.1", + "ports": { + "inputs": { + "references": "image[]?" + }, + "outputs": { + "image": "image/png" + }, + "params": { + "background": { + "default": "auto", + "enum": [ + "opaque", + "transparent", + "auto" + ] + }, + "prompt": { + "type": "string", + "x-fx-template": true + }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, + "size": { + "type": "string", + "x-fx-optional": true + }, + "vars": { + "default": {}, + "type": "object" + } + } + } + } + }, + "workflow": { + "file": "workflows/case.yaml", + "id": "case", + "title": "A declared provider" + } +} diff --git a/conformance/providers-declared/expected/plan.txt b/conformance/providers-declared/expected/plan.txt new file mode 100644 index 00000000..28c11453 --- /dev/null +++ b/conformance/providers-declared/expected/plan.txt @@ -0,0 +1,4 @@ +case · 1 phase +phase 1 1 steps 1 provider calls $0.01 – $0.04 +cached 0 of 1 known steps +estimate $0.01 – $0.04 diff --git a/conformance/providers-declared/in/fx.yaml b/conformance/providers-declared/in/fx.yaml new file mode 100644 index 00000000..b146ce4a --- /dev/null +++ b/conformance/providers-declared/in/fx.yaml @@ -0,0 +1,12 @@ +fx: project/v1 +routes: + image.generate: img-a@acme +providers: + acme: + credential: { variable: ACME_API_KEY, header: authorization, scheme: Bearer } + base_url: { variable: ACME_BASE_URL, default: "https://api.acme.test/v1" } + download_hosts: [cdn.acme.test, "*.media.acme.test"] + family: openai-compatible + local: + base_url: { default: "http://127.0.0.1:8080/v1" } + family: openai-compatible diff --git a/conformance/providers-declared/in/routes.yaml b/conformance/providers-declared/in/routes.yaml new file mode 100644 index 00000000..34cfaccb --- /dev/null +++ b/conformance/providers-declared/in/routes.yaml @@ -0,0 +1,4 @@ +fx: routes/v1 +routes: + - { capability: image.generate, route: img-a@acme, price: { low_usd: 0.01, high_usd: 0.04 } } + - { capability: structured.generate, route: text-a@acme, price: { cap_usd: 0.05 } } diff --git a/conformance/providers-declared/in/schemas/colour.json b/conformance/providers-declared/in/schemas/colour.json new file mode 100644 index 00000000..add7a4d5 --- /dev/null +++ b/conformance/providers-declared/in/schemas/colour.json @@ -0,0 +1 @@ +{"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]} diff --git a/conformance/providers-declared/in/workflows/case.yaml b/conformance/providers-declared/in/workflows/case.yaml new file mode 100644 index 00000000..7365175e --- /dev/null +++ b/conformance/providers-declared/in/workflows/case.yaml @@ -0,0 +1,9 @@ +fx: workflow/v1 +id: case +title: A declared provider +steps: + draw: + uses: fx/image.generate@1 + with: { prompt: a kite } +outputs: + image: ${{ steps.draw.outputs.image }} diff --git a/conformance/providers-declared/in/workflows/chat.yaml b/conformance/providers-declared/in/workflows/chat.yaml new file mode 100644 index 00000000..7c3c89f3 --- /dev/null +++ b/conformance/providers-declared/in/workflows/chat.yaml @@ -0,0 +1,12 @@ +fx: workflow/v1 +id: chat +title: A declared family's chat route +steps: + name: + uses: fx/structured.generate@1 + route: text-a@acme + with: + prompt: Name a colour. + schema: ./schemas/colour.json +outputs: + name: ${{ steps.name.outputs.json }} diff --git a/conformance/refusals/expected/expand.json b/conformance/refusals/expected/expand.json index a070fc00..addecec4 100644 --- a/conformance/refusals/expected/expand.json +++ b/conformance/refusals/expected/expand.json @@ -238,6 +238,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -272,6 +276,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "system": { "type": "string", "x-fx-optional": true, @@ -301,6 +309,10 @@ "type": "array", "x-fx-optional": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "question": { "type": "string", "x-fx-optional": true, diff --git a/conformance/route-extends/case.yaml b/conformance/route-extends/case.yaml new file mode 100644 index 00000000..646781c3 --- /dev/null +++ b/conformance/route-extends/case.yaml @@ -0,0 +1,43 @@ +# A route that extends another (identity.md §7): img-b@acme takes img-a@acme's price, features +# and contract, restating one contract member; img-c@acme restates its price and features and +# keeps the contract. extends is not hashed, so img-c's fingerprint differs from img-a's only by +# its model. A base no table read so far holds, or one on another provider, is refused (exit 2). +steps: +- argv: + - expand + - case + - --routes + - routes.yaml + save: expand.json + json: true + status: 0 +- files: + missing.yaml: | + fx: routes/v1 + routes: + - { capability: image.generate, route: img-d@acme, extends: img-z@acme } + argv: + - expand + - case + - --routes + - routes.yaml + - --routes + - missing.yaml + status: 2 + mentions: + - "missing.yaml: routes.0.extends: no route img-z@acme serves image.generate" +- files: + other.yaml: | + fx: routes/v1 + routes: + - { capability: image.generate, route: img-b@acme, extends: img-a@other } + argv: + - expand + - case + - --routes + - routes.yaml + - --routes + - other.yaml + status: 2 + mentions: + - "other.yaml: routes.0.extends: img-a@other is not a acme route" diff --git a/conformance/route-extends/expected/expand.json b/conformance/route-extends/expected/expand.json new file mode 100644 index 00000000..7a97a753 --- /dev/null +++ b/conformance/route-extends/expected/expand.json @@ -0,0 +1,168 @@ +{ + "estimate": { + "ceiling_usd": null, + "high_usd": 0.09, + "low_usd": 0.05 + }, + "instances": [ + { + "bindings": [], + "id": "base#1", + "identity": "e7aa86798258d531666f9376b51d3e7d017feab20937f6f060d993250322e3d5", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "base", + "phase": 1, + "price": { + "high_usd": 0.04, + "low_usd": 0.02 + }, + "reads": [], + "routes": { + "image.generate": { + "fingerprint": "4e7c8fd3c925cc1b49ab330d079ee20f6d86ed8e210cd1f9db53c97970990910", + "route": "img-a@acme" + } + }, + "state": "planned", + "step": "base", + "take": [ + 1 + ], + "type": "fx/image.generate@1.1", + "uses": "fx/image.generate@1", + "view": false, + "waiting_on": [], + "with": { + "background": "transparent", + "prompt": "a kite", + "vars": {} + } + }, + { + "bindings": [], + "id": "low#1", + "identity": "8ee2e8cda28cfbd7df30f7449ba7e42ce070de7e05c7bacd8ea26b1042da81eb", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "low", + "phase": 1, + "price": { + "high_usd": 0.04, + "low_usd": 0.02 + }, + "reads": [], + "routes": { + "image.generate": { + "fingerprint": "29334b650d4045c0c7a851ffb0c3f338df819a4f8010822fb14007f6447c5c2c", + "route": "img-b@acme" + } + }, + "state": "planned", + "step": "low", + "take": [ + 1 + ], + "type": "fx/image.generate@1.1", + "uses": "fx/image.generate@1", + "view": false, + "waiting_on": [], + "with": { + "background": "transparent", + "prompt": "a kite", + "vars": {} + } + }, + { + "bindings": [], + "id": "cheap#1", + "identity": "6ce7732d15fb813a1e9b73e572455004af0970794e189ded410b7f8994b81335", + "interface_bindings": [], + "judged_by": [], + "judges": null, + "key": null, + "needs": [], + "path": "cheap", + "phase": 1, + "price": { + "high_usd": 0.01, + "low_usd": 0.01 + }, + "reads": [], + "routes": { + "image.generate": { + "fingerprint": "2c32c3d5086eba4c6af6c602fc97415fa9b1f58d0e03143a8927a33bac1876e4", + "route": "img-c@acme" + } + }, + "state": "planned", + "step": "cheap", + "take": [ + 1 + ], + "type": "fx/image.generate@1.1", + "uses": "fx/image.generate@1", + "view": false, + "waiting_on": [], + "with": { + "background": "auto", + "prompt": "a kite", + "vars": {} + } + } + ], + "kind": "fx-graph-v1", + "pending": [], + "problems": [], + "scopes": [], + "types": { + "fx/image.generate@1": { + "identity": "fx/image.generate@1.1", + "ports": { + "inputs": { + "references": "image[]?" + }, + "outputs": { + "image": "image/png" + }, + "params": { + "background": { + "default": "auto", + "enum": [ + "opaque", + "transparent", + "auto" + ] + }, + "prompt": { + "type": "string", + "x-fx-template": true + }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, + "size": { + "type": "string", + "x-fx-optional": true + }, + "vars": { + "default": {}, + "type": "object" + } + } + } + } + }, + "workflow": { + "file": "workflows/case.yaml", + "id": "case", + "title": "Extended routes" + } +} diff --git a/conformance/route-extends/in/fx.yaml b/conformance/route-extends/in/fx.yaml new file mode 100644 index 00000000..ef9216d8 --- /dev/null +++ b/conformance/route-extends/in/fx.yaml @@ -0,0 +1,3 @@ +fx: project/v1 +routes: + image.generate: img-a@acme diff --git a/conformance/route-extends/in/routes.yaml b/conformance/route-extends/in/routes.yaml new file mode 100644 index 00000000..48ac3af2 --- /dev/null +++ b/conformance/route-extends/in/routes.yaml @@ -0,0 +1,10 @@ +fx: routes/v1 +routes: + - capability: image.generate + route: img-a@acme + price: { low_usd: 0.02, high_usd: 0.04 } + features: [text_to_image, alpha] + concurrency: 2 + contract: { quality: high, sizes: [1024x1024] } + - { capability: image.generate, route: img-b@acme, extends: img-a@acme, contract: { quality: low } } + - { capability: image.generate, route: img-c@acme, extends: img-a@acme, price: { usd: 0.01 }, features: [text_to_image] } diff --git a/conformance/route-extends/in/workflows/case.yaml b/conformance/route-extends/in/workflows/case.yaml new file mode 100644 index 00000000..a8cfa41c --- /dev/null +++ b/conformance/route-extends/in/workflows/case.yaml @@ -0,0 +1,15 @@ +fx: workflow/v1 +id: case +title: Extended routes +steps: + base: + uses: fx/image.generate@1 + with: { prompt: a kite, background: transparent } + low: + uses: fx/image.generate@1 + route: img-b@acme + with: { prompt: a kite, background: transparent } + cheap: + uses: fx/image.generate@1 + route: img-c@acme + with: { prompt: a kite } diff --git a/conformance/run.py b/conformance/run.py index 24aeeea5..50f8b5ef 100644 --- a/conformance/run.py +++ b/conformance/run.py @@ -68,7 +68,14 @@ "mentions", "never_mentions", "absent", + "env", } +# A step's own environment variables (`env`): a name of the shell's form, never one the runner +# sets for every step, `NODE_OPTIONS` or an FX setting, so no step can turn the network on, read a +# `.env`, choose another host or reach FX's own store. +ENV_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +RESERVED_ENV_PREFIX = "GRIDA_FX_" +RESERVED_ENV = {"NODE_OPTIONS"} # The ways a saved text may be compared by meaning; a step uses at most one. FORMATS = ("json", "jsonl", "yaml") # Run-event members whose values differ between machines and invocations. They are dropped only @@ -230,7 +237,8 @@ def step_environment(home: Path, passed: dict[str, str]) -> dict[str, str]: No provider key, tool override, FX cache location or locale can change what a case prints, and a case that runs `--live` reaches no provider: the network is off and no `.env` is read - (spec/providers.md sections 2 and 3). + (spec/adapters.md section 2; spec/providers.md section 1). A step's `env` adds to this, never + over it (check_step_env): a made-up key for a provider the case declares. """ return { @@ -393,9 +401,29 @@ def load_case(case: Path) -> list[dict[str, Any]]: absent = step.get("absent", []) if not isinstance(absent, list) or not all(map(_is_path, absent)): raise CaseFailure(f"{where}: absent is a list of relative paths inside the project") + if "env" in step: + check_step_env(step["env"], argv is not None, where) return steps +def check_step_env(given: object, has_argv: bool, where: str) -> None: + """A step's `env`: variables for its command alone, beside the minimal environment.""" + + if not has_argv: + raise CaseFailure(f"{where}: env needs argv") + if not isinstance(given, dict) or not all( + isinstance(name, str) and isinstance(value, str) for name, value in given.items() + ): + raise CaseFailure(f"{where}: env maps variable names to text") + reserved = set(step_environment(Path(), {})) | {PYTHON_HOST, NODE_HOST, *TOOLCHAIN_HOMES} + reserved |= RESERVED_ENV + for name in given: + if not ENV_NAME.fullmatch(name): + raise CaseFailure(f"{where}: env: {name!r} is not a variable name") + if name in reserved or name.upper().startswith(RESERVED_ENV_PREFIX): + raise CaseFailure(f"{where}: env: {name} is the runner's to set") + + def run_case( case: Path, work: Path, @@ -430,7 +458,12 @@ def run_case( label = f"step {number} ({shlex.join(step['argv'])})" stdin = step["stdin"].encode("utf-8") if "stdin" in step else None status, stdout, stderr = run_command( - [*command, *step["argv"]], project, env, stdin, timeout_s, label + [*command, *step["argv"]], + project, + {**env, **step.get("env", {})}, + stdin, + timeout_s, + label, ) expected_status = step.get("status", 0) if status != expected_status: diff --git a/conformance/stand-in-agent-ts/expected/events-one.json b/conformance/stand-in-agent-ts/expected/events-one.json index 6aea066d..92ef0d6c 100644 --- a/conformance/stand-in-agent-ts/expected/events-one.json +++ b/conformance/stand-in-agent-ts/expected/events-one.json @@ -58,6 +58,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "system": { "type": "string", "x-fx-optional": true, @@ -103,6 +107,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "system": { "type": "string", "x-fx-optional": true, @@ -246,6 +254,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "system": { "type": "string", "x-fx-optional": true, diff --git a/conformance/stand-in-agent/expected/events-one.json b/conformance/stand-in-agent/expected/events-one.json index 2c8607fb..b5901cc9 100644 --- a/conformance/stand-in-agent/expected/events-one.json +++ b/conformance/stand-in-agent/expected/events-one.json @@ -58,6 +58,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "system": { "type": "string", "x-fx-optional": true, @@ -103,6 +107,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "system": { "type": "string", "x-fx-optional": true, @@ -246,6 +254,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "system": { "type": "string", "x-fx-optional": true, diff --git a/conformance/stand-in-errors-ts/expected/events-one.json b/conformance/stand-in-errors-ts/expected/events-one.json index c3e80408..99746cea 100644 --- a/conformance/stand-in-errors-ts/expected/events-one.json +++ b/conformance/stand-in-errors-ts/expected/events-one.json @@ -31,6 +31,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -76,6 +80,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -121,6 +129,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -166,6 +178,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -211,6 +227,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -256,6 +276,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -323,6 +347,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -368,6 +396,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -439,6 +471,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -503,6 +539,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -567,6 +607,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -631,6 +675,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -695,6 +743,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -759,6 +811,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -823,6 +879,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -887,6 +947,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -951,6 +1015,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-errors-ts/expected/events-two.json b/conformance/stand-in-errors-ts/expected/events-two.json index 66cd8724..ace60df6 100644 --- a/conformance/stand-in-errors-ts/expected/events-two.json +++ b/conformance/stand-in-errors-ts/expected/events-two.json @@ -31,6 +31,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -76,6 +80,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -121,6 +129,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -166,6 +178,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -211,6 +227,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -256,6 +276,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -323,6 +347,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -368,6 +396,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -439,6 +471,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -503,6 +539,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -567,6 +607,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -631,6 +675,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -695,6 +743,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -759,6 +811,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -823,6 +879,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -887,6 +947,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -951,6 +1015,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-errors/expected/events-one.json b/conformance/stand-in-errors/expected/events-one.json index 925e5263..60d73325 100644 --- a/conformance/stand-in-errors/expected/events-one.json +++ b/conformance/stand-in-errors/expected/events-one.json @@ -31,6 +31,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -76,6 +80,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -121,6 +129,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -166,6 +178,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -211,6 +227,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -256,6 +276,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -323,6 +347,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -368,6 +396,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -439,6 +471,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -503,6 +539,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -567,6 +607,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -631,6 +675,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -695,6 +743,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -759,6 +811,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -823,6 +879,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -887,6 +947,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -951,6 +1015,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-errors/expected/events-two.json b/conformance/stand-in-errors/expected/events-two.json index be077eb4..740f1f77 100644 --- a/conformance/stand-in-errors/expected/events-two.json +++ b/conformance/stand-in-errors/expected/events-two.json @@ -31,6 +31,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -76,6 +80,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -121,6 +129,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -166,6 +178,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -211,6 +227,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -256,6 +276,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -323,6 +347,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -368,6 +396,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -439,6 +471,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -503,6 +539,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -567,6 +607,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -631,6 +675,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -695,6 +743,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -759,6 +811,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -823,6 +879,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -887,6 +947,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -951,6 +1015,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-flags-ts/expected/events.json b/conformance/stand-in-flags-ts/expected/events.json index 0a009a62..be417810 100644 --- a/conformance/stand-in-flags-ts/expected/events.json +++ b/conformance/stand-in-flags-ts/expected/events.json @@ -31,6 +31,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -74,6 +78,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-flags/expected/events.json b/conformance/stand-in-flags/expected/events.json index 6ac19495..9b198ca0 100644 --- a/conformance/stand-in-flags/expected/events.json +++ b/conformance/stand-in-flags/expected/events.json @@ -31,6 +31,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -74,6 +78,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image-ts/expected/events-one.json b/conformance/stand-in-image-ts/expected/events-one.json index 97ebda0b..5eea76c9 100644 --- a/conformance/stand-in-image-ts/expected/events-one.json +++ b/conformance/stand-in-image-ts/expected/events-one.json @@ -29,6 +29,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image-ts/expected/events-other.json b/conformance/stand-in-image-ts/expected/events-other.json index b4aab33b..597280a3 100644 --- a/conformance/stand-in-image-ts/expected/events-other.json +++ b/conformance/stand-in-image-ts/expected/events-other.json @@ -29,6 +29,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image-ts/expected/events-resumed.json b/conformance/stand-in-image-ts/expected/events-resumed.json index 8f048193..cce7ee11 100644 --- a/conformance/stand-in-image-ts/expected/events-resumed.json +++ b/conformance/stand-in-image-ts/expected/events-resumed.json @@ -29,6 +29,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image-ts/expected/events-two.json b/conformance/stand-in-image-ts/expected/events-two.json index 17038847..7bb1e43c 100644 --- a/conformance/stand-in-image-ts/expected/events-two.json +++ b/conformance/stand-in-image-ts/expected/events-two.json @@ -29,6 +29,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image-ts/expected/plan-one.json b/conformance/stand-in-image-ts/expected/plan-one.json index 47463760..4d84963b 100644 --- a/conformance/stand-in-image-ts/expected/plan-one.json +++ b/conformance/stand-in-image-ts/expected/plan-one.json @@ -86,6 +86,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image-ts/expected/plan-plain.json b/conformance/stand-in-image-ts/expected/plan-plain.json index 949766b7..8700dc66 100644 --- a/conformance/stand-in-image-ts/expected/plan-plain.json +++ b/conformance/stand-in-image-ts/expected/plan-plain.json @@ -85,6 +85,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image/expected/events-one.json b/conformance/stand-in-image/expected/events-one.json index 97ebda0b..5eea76c9 100644 --- a/conformance/stand-in-image/expected/events-one.json +++ b/conformance/stand-in-image/expected/events-one.json @@ -29,6 +29,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image/expected/events-other.json b/conformance/stand-in-image/expected/events-other.json index b4aab33b..597280a3 100644 --- a/conformance/stand-in-image/expected/events-other.json +++ b/conformance/stand-in-image/expected/events-other.json @@ -29,6 +29,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image/expected/events-resumed.json b/conformance/stand-in-image/expected/events-resumed.json index 8f048193..cce7ee11 100644 --- a/conformance/stand-in-image/expected/events-resumed.json +++ b/conformance/stand-in-image/expected/events-resumed.json @@ -29,6 +29,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image/expected/events-two.json b/conformance/stand-in-image/expected/events-two.json index 17038847..7bb1e43c 100644 --- a/conformance/stand-in-image/expected/events-two.json +++ b/conformance/stand-in-image/expected/events-two.json @@ -29,6 +29,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image/expected/plan-one.json b/conformance/stand-in-image/expected/plan-one.json index 47463760..4d84963b 100644 --- a/conformance/stand-in-image/expected/plan-one.json +++ b/conformance/stand-in-image/expected/plan-one.json @@ -86,6 +86,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-image/expected/plan-plain.json b/conformance/stand-in-image/expected/plan-plain.json index 949766b7..8700dc66 100644 --- a/conformance/stand-in-image/expected/plan-plain.json +++ b/conformance/stand-in-image/expected/plan-plain.json @@ -85,6 +85,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/stand-in-job/expected/events-one.json b/conformance/stand-in-job/expected/events-one.json index 46e3136c..f2bf505a 100644 --- a/conformance/stand-in-job/expected/events-one.json +++ b/conformance/stand-in-job/expected/events-one.json @@ -30,6 +30,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "resolution": { "type": "string", "x-fx-optional": true @@ -84,6 +88,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -151,6 +159,10 @@ "default": false, "type": "boolean" }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "quad": { "default": false, "type": "boolean" diff --git a/conformance/takes-pick/expected/expand.json b/conformance/takes-pick/expected/expand.json index 65242e13..a3445256 100644 --- a/conformance/takes-pick/expected/expand.json +++ b/conformance/takes-pick/expected/expand.json @@ -402,6 +402,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/template-prompt/expected/expand.json b/conformance/template-prompt/expected/expand.json index 6fbc8d6f..3e86989c 100644 --- a/conformance/template-prompt/expected/expand.json +++ b/conformance/template-prompt/expected/expand.json @@ -214,6 +214,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/workflows-setting/expected/plan-thing.json b/conformance/workflows-setting/expected/plan-thing.json index e5a32ddf..16631da9 100644 --- a/conformance/workflows-setting/expected/plan-thing.json +++ b/conformance/workflows-setting/expected/plan-thing.json @@ -105,6 +105,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true @@ -131,6 +135,10 @@ "prompt": { "type": "string", "x-fx-template": true + }, + "provider_options": { + "type": "object", + "x-fx-optional": true } } } diff --git a/conformance/yaml-strict/expected/expand-plain.json b/conformance/yaml-strict/expected/expand-plain.json index ff45f89e..3891b0fe 100644 --- a/conformance/yaml-strict/expected/expand-plain.json +++ b/conformance/yaml-strict/expected/expand-plain.json @@ -70,6 +70,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/conformance/yaml-strict/expected/expand-quoted.json b/conformance/yaml-strict/expected/expand-quoted.json index 80c7750f..3cdbdab1 100644 --- a/conformance/yaml-strict/expected/expand-quoted.json +++ b/conformance/yaml-strict/expected/expand-quoted.json @@ -70,6 +70,10 @@ "type": "string", "x-fx-template": true }, + "provider_options": { + "type": "object", + "x-fx-optional": true + }, "size": { "type": "string", "x-fx-optional": true diff --git a/crates/grida-fx-core/Cargo.toml b/crates/grida-fx-core/Cargo.toml index 8b9a0da1..770eaf18 100644 --- a/crates/grida-fx-core/Cargo.toml +++ b/crates/grida-fx-core/Cargo.toml @@ -18,6 +18,8 @@ indexmap.workspace = true yaml-rust2.workspace = true jsonschema.workspace = true regex.workspace = true +# Base URLs of declared providers (src/providers.rs). +url.workspace = true png.workspace = true gif.workspace = true image-webp.workspace = true diff --git a/crates/grida-fx-core/src/adapters.rs b/crates/grida-fx-core/src/adapters.rs new file mode 100644 index 00000000..551c8d4a --- /dev/null +++ b/crates/grida-fx-core/src/adapters.rs @@ -0,0 +1,362 @@ +//! A planning project's own adapters while planning (spec/adapters.md §9; spec/identity.md §6, +//! §7). +//! +//! [`load_declared`] loads each adapter the planning project declares (`providers..adapter: +//! #`), in the declarations' order, with `adapter.load` on the planning host of +//! the module's language (spec/protocol.md §5.8), after pointing the host at the planning project. +//! It learns the adapter's version and each capability's kind, reads and hashes the source closure +//! the host reports itself ([`crate::registry::closure_digests`], no resources), and holds a +//! versioned adapter's digest to the planning project's fx.lock `adapters`: a mismatch is the +//! adapter's drift, which every step on a route it serves gets as a problem +//! ([`crate::routes::Route::drift`]). What it learns goes into the declaration +//! ([`LoadedAdapter`]), so the route tables read after it complete their contracts from it. +//! +//! An adapter that cannot be loaded refuses the command (exit 2), `fx.yaml: +//! providers..adapter: `, the sentence one of, in the order they are found: +//! - `no file `; ` is outside the project`, once symbolic links are resolved; +//! - a suffix refusal of spec/protocol.md §1 ([`Language::of_module`]: `no node host for . +//! modules`, …); +//! - the host's `load_failed` message (` failed to import: …`, `: is not +//! declared with adapter()`, the SDK to upgrade for a host that predates adapters), or why the +//! host is unavailable; +//! - `# declares no capability`; +//! - a closure the engine cannot hash (`cannot read