Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ a candidate do not establish public availability or authorize publication.

## [Unreleased]

### Hosted service

- Hold quotes follow the service's per-run holds. `run quote` and the
`run submit` plan send the given `--model`, `--reasoning-effort` and
`--environment`, plus `repositories=1` for any `--repo` or
`--commit-output`; the `job create` plan sends the spec's `model`,
`reasoning_effort` and `environment`, plus `repositories=1` for a
`repository_url` or `context_repository_url`. Nothing is defaulted, so a
flagless quote is unchanged. `holdBasis`, the human quote and the help no
longer call the hold flat.
- `job create` webhook specs accept `model`, `reasoning_effort`,
`repository_url`, `repository_branch` and `output` for the connected
contract (the service requires `program_ref` with them).

## [0.15.0-rc.2] — 2026-10-02

These are accumulated changes in the 0.15 candidate train; some capabilities
Expand Down
20 changes: 15 additions & 5 deletions cli/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2469,14 +2469,23 @@ sorted keys of `inputs` as `inputKeys`, `interval_seconds`, `delivery_mode`,
`visibility`, `role`, `amount_cents`), omitted when none is present; secrets,
program text and input values never appear. Human previews print it as
`Summary: field=value; ...` (cents also in dollars) and `CONFIRMATION_REQUIRED`
as `Planned summary:`; a plan with a quote prints `Hold: $X (flat hold,
independent of program and model)`. `run submit` and `program draft` check a
as `Planned summary:`; a plan with a quote prints `Hold: $X (set aside from
the wallet while the run is live; not its price)`. `run submit` and `program draft` check a
`--model` against `GET /models` (manifest request `when: "--model"`) before the
confirmation gate: a name that is neither offered nor the default is
`INVOCATION_INVALID` naming the three nearest offered
models, with `suggestedArgv` the same command using the nearest; a failed
lookup is advisory (the service decides) except an interrupt. `program draft`
quotes the flat run hold (`GET /run/quote`, advisory) into its plan. `org
quotes the run hold (`GET /run/quote` with no parameters, advisory) into its
plan. The hold depends on model, reasoning effort, environment, declared tools
and bound repositories. `run quote` and the `run submit` plan send only the
`model`, `reasoning_effort` and `environment` the user gave, plus
`repositories=1` for any `--repo` or `--commit-output`; the `job create` plan
sends the spec's `model`, `reasoning_effort` and `environment`, plus
`repositories=1` for a `repository_url` or `context_repository_url`. Nothing
is defaulted, the program is not inspected for the quote and tools are never
sent, so a quote does not cover the program's own run settings or declared
tools. `org
create` checks the service slug rule (1-63 lowercase letters, digits or
interior hyphens; not `openprose`, `system`, `default`, `invitations` or
UUID-shaped) before any request, suggesting a derived slug as `suggestedArgv`
Expand All @@ -2495,8 +2504,9 @@ the `--yes` help line reads `required because <confirmReason>.` and
`CONFIRMATION_REQUIRED` carries `details.reason` `--yes is required because
<confirmReason>`; `render_service_help.py --check` and the manifest schema
refuse a missing, misplaced or circular reason. `run quote` results carry
`holdBasis` and its human output says the hold is flat and the price is known
only after settlement.
`holdBasis` and its human output says what the hold depends on, that the
quote covers only the options given, and that the price is known only after
settlement.

**Transport.** Requests never follow redirects and are never retried, not even
GETs. Each operation has a transport class with bounded time and size:
Expand Down
23 changes: 23 additions & 0 deletions cli/bun/src/core/service/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ export function requestFor(operation: ManifestOperation, index: number, path: st
};
}

/** The options a `GET /run/quote` hold depends on, as the caller gave them. */
export interface HoldOptions {
model?: string | undefined;
reasoningEffort?: string | undefined;
environment?: string | undefined;
repositoriesBound: boolean;
}

/**
* The `GET /run/quote` query (manifest order): only what was given, never a
* default, so a quote with no such option stays parameter-free. Any bound
* repository sends `repositories=1`; declared tools are never sent, because
* the CLI does not read the program.
*/
export function holdQuery(options: HoldOptions): Array<[string, string]> {
const query: Array<[string, string]> = [];
if (options.model !== undefined) query.push(["model", options.model]);
if (options.reasoningEffort !== undefined) query.push(["reasoning_effort", options.reasoningEffort]);
if (options.environment !== undefined) query.push(["environment", options.environment]);
if (options.repositoriesBound) query.push(["repositories", "1"]);
return query;
}

/** Sets a compact JSON body with sorted keys (both products serialize identically). */
export function withJsonBody(request: Request, value: Json): Request {
return { ...request, body: new TextEncoder().encode(canonicalJson(value)) };
Expand Down
28 changes: 23 additions & 5 deletions cli/bun/src/core/service/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { failure, invocationFailure } from "../errors";
import { humanSafeScalar, quote as quoteText } from "../output";
import { RunnerFailure } from "../types";
import { readSource } from "./fs";
import { encodeSegment, jsonObject, parseJson, requestFor, type Request } from "./http";
import { encodeSegment, holdQuery, jsonObject, parseJson, requestFor, type Request } from "./http";
import type { Context } from "./index";
import { didYouMean, type Environment, type Json, type JsonObject } from "./manifest";
import { parseOwnAllowed, parseProgramRef, pinned, resolveToRun, validSlug } from "./program-ref";
Expand Down Expand Up @@ -584,9 +584,27 @@ async function show(context: Context): Promise<Json> {
return result;
}

/** The anonymous GET /run/quote hold for the confirmation plan (default environment); the price policy reference stays internal. */
async function quote(context: Context): Promise<JsonObject> {
const body = await getJson(context, 0, "/run/quote");
/** A spec key's value when it is a nonempty string (a hold option the spec gives). */
function specString(spec: JsonObject, key: string): string | undefined {
const value = spec[key];
return typeof value === "string" && value.length > 0 ? value : undefined;
}

/**
* The anonymous GET /run/quote hold for the confirmation plan, quoted from the
* spec's model, reasoning effort, environment and bound repositories (only
* those the spec gives); the price policy reference stays internal.
*/
async function quote(context: Context, spec: JsonObject): Promise<JsonObject> {
const repository = (key: string): boolean => (specString(spec, key) ?? "").length > 0;
const request = requestFor(context.operation, 0, "/run/quote");
request.query.push(...holdQuery({
model: specString(spec, "model"),
reasoningEffort: specString(spec, "reasoning_effort"),
environment: specString(spec, "environment"),
repositoriesBound: repository("repository_url") || repository("context_repository_url"),
}));
const body = jsonObject(await context.send(request));
const hold = object(body.hold ?? null, "quote.hold");
const holdUsd = hold.hold_usd;
if (typeof holdUsd !== "string" || !/^-?[0-9]+\.[0-9]{2}$/u.test(holdUsd)) throw protocol("quote.hold.hold_usd");
Expand Down Expand Up @@ -617,7 +635,7 @@ async function create(context: Context): Promise<Json> {
const planned = context.planned(1, "/triggers", [], body);
// A webhook with no program starts no runs: nothing is held.
if (unpaid(context, spec)) planned.effect = "write";
else if (context.invocation.preview || !context.invocation.yes) planned.quote = await quote(context);
else if (context.invocation.preview || !context.invocation.yes) planned.quote = await quote(context, spec);
const gate = context.gate(planned);
if (gate.kind === "preview") return gate.result;
const request: Request = { ...requestFor(context.operation, 1, "/triggers"), body };
Expand Down
2 changes: 1 addition & 1 deletion cli/bun/src/core/service/programs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -437,7 +437,7 @@ async function draft(context: Context): Promise<Json> {
const request: Request = { ...base, headers: [...base.headers, ["Accept", "text/event-stream"]] };
const planned = context.planned(0, "/write", [], request.body);
if (context.invocation.preview || !context.invocation.yes) {
// A draft reserves the same flat hold as a run (request 2, advisory).
// A draft reserves a hold as a run does (request 2, advisory; parameter-free).
const quote = await context.advisoryQuote(2);
if (quote !== undefined) planned.quote = quote;
}
Expand Down
2 changes: 1 addition & 1 deletion cli/bun/src/core/service/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ function firstRunLine(planned: JsonObject): string | undefined {
return `First run: about 1 second after the job is created, then every ${durationText(interval)}`;
}

/** The human hold line of a plan with a quote (The hold is flat, not an estimate of this run's price). */
/** The human hold line of a plan with a quote (the hold is a reservation, not an estimate of this run's price). */
function holdLine(planned: JsonObject): string | undefined {
const hold = ((planned.quote as JsonObject | undefined)?.hold as JsonObject | undefined)?.hold_usd;
return typeof hold === "string" ? `Hold: $${humanSafeScalar(hold)} (set aside from the wallet while the run is live; not its price)` : undefined;
Expand Down
30 changes: 22 additions & 8 deletions cli/bun/src/core/service/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { failure, hostedRunFailed, invocationFailure } from "../errors";
import { humanSafeMultiline, humanSafeScalar, quote as quoteText } from "../output";
import { RunnerFailure } from "../types";
import { readText, validRelativePath } from "./fs";
import { encodeSegment, jsonObject, parseJson, requestFor, type Request, type Response } from "./http";
import { encodeSegment, holdQuery, jsonObject, parseJson, requestFor, type Request, type Response } from "./http";
import type { Context } from "./index";
import { validSession, type JournalEntry } from "./journal";
import { didYouMean, manifest, type Environment, type Json, type JsonObject } from "./manifest";
Expand Down Expand Up @@ -255,8 +255,15 @@ async function quote(context: Context): Promise<Json> {
if (fallback === undefined || !validText(fallback, 64)) throw protocol("service status environments.default is missing or malformed");
environment = fallback;
}
// The hold depends on these options; only those given are sent. The
// program, inputs and runtime are accepted but never sent.
const request = requestFor(context.operation, 1, "/run/quote");
if (requested !== undefined) request.query.push(["environment", requested]);
request.query.push(...holdQuery({
model: context.option("--model"),
reasoningEffort: context.option("--reasoning-effort"),
environment: requested,
repositoriesBound: context.optionValues("--repo").length > 0 || context.option("--commit-output") !== undefined,
}));
const body = jsonObject(await context.send(request));
const { hold } = quoteFields(body);
let note = "";
Expand All @@ -265,15 +272,20 @@ async function quote(context: Context): Promise<Json> {
note = cleanLine(body.note, 512);
}
let text = `Environment: ${humanSafeScalar(environment)}\n`;
text += `Hold: $${humanSafeScalar(String(hold.hold_usd))}, set aside from the wallet while a run is live; not its price. The same for every program and model; released within ${String(hold.ttl_seconds)} s when unused\n`;
text += `Hold: $${humanSafeScalar(String(hold.hold_usd))}, set aside from the wallet while a run is live; not its price. Released within ${String(hold.ttl_seconds)} s when unused\n`;
text += `Depends on: ${HOLD_DEPENDS_ON}; ${HOLD_COVERAGE}\n`;
text += `Price: known only after a run settles; read it with \`${context.command("run show RUN_ID")}\`\n`;
if (note.length > 0) text += `Note: ${humanSafeScalar(note)}\n`;
context.human = text;
return { environment, hold, holdBasis: HOLD_BASIS, note };
}

/** `run quote` holdBasis: the hold is not a price estimate. */
const HOLD_BASIS = "flat hold, independent of program and model; a run's price is known only after it settles";
/** What the hold depends on (the service prices the hold from these). */
const HOLD_DEPENDS_ON = "model, reasoning effort, environment, declared tools and repositories";
/** What a CLI quote covers: it never reads the program. */
const HOLD_COVERAGE = "quoted from the options given, without the program's own run settings or declared tools";
/** `run quote` holdBasis: what the hold depends on; it is not a price estimate. */
const HOLD_BASIS = `depends on ${HOLD_DEPENDS_ON}; ${HOLD_COVERAGE}; a run's price is known only after it settles`;

// ---------------------------------------------------------------------------
// Event projection (decision 5) and terminal mapping (decision 9).
Expand Down Expand Up @@ -876,7 +888,8 @@ interface Submission {
sourceSha256: string | null;
session: string | undefined;
waitMs: number;
environment: string | undefined;
/** The `GET /run/quote` query of the plan: the hold options given. */
quoteQuery: Array<[string, string]>;
}

async function prepareSubmission(context: Context): Promise<Submission> {
Expand Down Expand Up @@ -955,7 +968,8 @@ async function prepareSubmission(context: Context): Promise<Submission> {
if (bytes.length > MAX_BODY_BYTES) {
throw invocationFailure(`the submission is ${bytes.length} bytes, above the ${MAX_BODY_BYTES}-byte limit; shrink the program or inputs`);
}
return { body: bytes, extraQuery, sourceSha256, session, waitMs, environment };
const quoteQuery = holdQuery({ model, reasoningEffort: effort, environment, repositoriesBound: repositories.length > 0 || commit !== undefined });
return { body: bytes, extraQuery, sourceSha256, session, waitMs, quoteQuery };
}

/**
Expand Down Expand Up @@ -1048,7 +1062,7 @@ async function submit(context: Context): Promise<Json> {
const query = submitQuery(submission.session ?? "{session}", submission.extraQuery);
const planned = context.planned(2, "/run", query, submission.body);
const quoteRequest: Request = { ...requestFor(context.operation, 1, "/run/quote"), class: "control" };
if (submission.environment !== undefined) quoteRequest.query.push(["environment", submission.environment]);
quoteRequest.query.push(...submission.quoteQuery);
// The quote is advisory: a failed quote never hides the plan.
try {
const { hold } = quoteFields(jsonObject(await context.send(quoteRequest)));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
{
"arguments": [
{
"description": "Program file, or - for standard input, as `cli run submit` takes it. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.",
"description": "Program file, or - for standard input, as `cli run submit` takes it. Accepted so a quote names the same command as `cli run submit`; the quote does not read the program, so the program's own run settings and declared tools are not included.",
"name": "FILE",
"required": false,
"variadic": false
Expand Down Expand Up @@ -74,7 +74,7 @@
"mutation": false,
"options": [
{
"description": "Run a saved program; a bare SLUG is your own program. @REV is a rev_id, or @N (@revN) for revision N of your own program; without @REV the latest revision is pinned. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.",
"description": "Run a saved program; a bare SLUG is your own program. @REV is a rev_id, or @N (@revN) for revision N of your own program; without @REV the latest revision is pinned. Accepted so a quote names the same command as `cli run submit`; the quote does not read the program, so the program's own run settings and declared tools are not included.",
"name": "--from",
"repeatable": false,
"required": false,
Expand All @@ -96,7 +96,7 @@
},
{
"default": "the service's default_model (`cli model list`)",
"description": "Hosted model id (see `cli model list`). Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.",
"description": "Hosted model id (see `cli model list`). Sent with the quote: the hold depends on it.",
"name": "--model",
"repeatable": false,
"required": false,
Expand All @@ -110,15 +110,15 @@
"high",
"xhigh"
],
"description": "Reasoning effort supported by the model. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.",
"description": "Reasoning effort supported by the model. Sent with the quote: the hold depends on it.",
"name": "--reasoning-effort",
"repeatable": false,
"required": false,
"value": "EFFORT"
},
{
"default": "the service's default environment",
"description": "Execution environment offered by the service (for example builtin or linux).",
"description": "Execution environment offered by the service (for example builtin or linux). Sent with the quote: the hold depends on it.",
"name": "--environment",
"repeatable": false,
"required": false,
Expand All @@ -133,14 +133,14 @@
"value": "RUNTIME"
},
{
"description": "Read-only repository context. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.",
"description": "Read-only repository context. Any --repo or --commit-output sends repositories=1 with the quote: a bound repository adds to the hold.",
"name": "--repo",
"repeatable": true,
"required": false,
"value": "OWNER/NAME[@BRANCH]"
},
{
"description": "Writable repository that receives the run's commit. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.",
"description": "Writable repository that receives the run's commit. Any --repo or --commit-output sends repositories=1 with the quote: a bound repository adds to the hold.",
"name": "--commit-output",
"repeatable": false,
"required": false,
Expand All @@ -153,7 +153,7 @@
"stream": false
},
"preview": false,
"summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold is flat, the same for every program and model, and what the run does not use is released when it settles. A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). The program, inputs and model `cli run submit` takes are accepted, so you can quote the exact command you will submit."
"summary": "Report the hold a run reserves: money set aside from the wallet while the run is live. It is not the price. The environment is checked first.\nThe hold depends on the model, reasoning effort, environment, declared tools and bound repositories, and what the run does not use is released when it settles. The quote sends only the --model, --reasoning-effort, --environment, --repo and --commit-output you give. It does not read the program, so a program that sets its own model, effort or environment, or declares tools, can hold a different amount. A run's price is known only after it settles (`cli run show RUN_ID`, price_cents). The program, inputs and options `cli run submit` takes are accepted, so you can quote the exact command you will submit."
},
{
"arguments": [
Expand Down
Loading
Loading