From ef73630127087a1c6dc20f5f418fd5c4238186c3 Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp <19483938+rawwerks@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:44:53 -0400 Subject: [PATCH 1/2] Quote holds for the program, not only the given options The service now prices a program itself: GET /run/quote?program_ref= for a saved program and POST /run/quote with the POST /run body. The run submit plan posts its exact submission body; run quote FILE posts the program and run quote --from names it (resolved and checked as run submit does); the job create plan names its pinned program_ref. Explicit options still override the program's own settings, and the CLI still never inspects program text. run quote reports the service's basis (each input with its source), and program_not_found maps to SERVICE_RESOURCE_NOT_FOUND. sync_service_interactions.py accepts the service's public interaction projection (interactions/2, no principals table); the catalog is re-vendored from it. Co-Authored-By: Claude Opus 5.5 --- cli/CHANGELOG.md | 9 + cli/SPEC.md | 26 +- cli/bun/src/core/service/http.ts | 4 +- cli/bun/src/core/service/index.ts | 12 + cli/bun/src/core/service/jobs.ts | 10 +- cli/bun/src/core/service/program-ref.ts | 12 +- cli/bun/src/core/service/programs.ts | 5 +- cli/bun/src/core/service/runs.ts | 221 +++++++-- cli/bun/test/service-runs.test.ts | 28 +- cli/ci/sync_service_interactions.py | 17 +- .../framework/help-json-group-records.json | 16 +- .../framework/service-capabilities-json.json | 4 +- .../framework/service-capabilities-jsonl.json | 4 +- .../framework/service-guide-human.json | 2 +- .../service/framework/service-guide-json.json | 4 +- .../framework/service-guide-jsonl.json | 4 +- .../jobs/job-create-bare-slug-pins.json | 11 +- ...ob-create-confirmation-required-quote.json | 11 +- .../jobs/job-create-preview-human.json | 11 +- .../jobs/job-create-preview-quote.json | 11 +- .../job-create-quote-program-not-found.json | 73 +++ .../jobs/job-create-quote-unavailable.json | 11 +- .../jobs/job-create-rev-number-human.json | 11 +- .../jobs/job-create-rev-number-pins.json | 11 +- ...b-create-schedule-empty-strings-quote.json | 13 +- ...job-create-schedule-environment-quote.json | 11 +- .../service/jobs/job-create-unpinned-ref.json | 11 +- .../job-create-webhook-binding-quote.json | 3 +- ...ob-create-webhook-with-program-quotes.json | 3 +- .../runs/alias-run-create-previews.json | 8 +- .../runs/quote-accepts-submit-arguments.json | 47 +- .../quote-basis-extra-fields-dropped.json | 151 +++++++ .../service/runs/quote-basis-malformed.json | 122 +++++ .../runs/quote-commit-output-only.json | 2 +- ...te-default-environment-unknown-fields.json | 2 +- .../runs/quote-default-environment.json | 2 +- .../runs/quote-file-and-from-refused.json | 62 +++ .../service/runs/quote-file-anonymous.json | 147 ++++++ .../quote-file-commit-output-needs-repo.json | 66 +++ .../runs/quote-file-posts-run-body.json | 171 +++++++ ...ote-file-unknown-environment-rejected.json | 96 ++++ .../quote-file-unknown-runtime-rejected.json | 101 +++++ .../quote-from-own-commit-id-corrected.json | 118 +++++ .../service/runs/quote-from-own-latest.json | 176 ++++++++ .../runs/quote-from-own-revision-number.json | 176 ++++++++ .../runs/quote-from-program-anonymous.json | 146 ++++++ .../runs/quote-from-program-basis.json | 149 ++++++ .../runs/quote-from-program-not-found.json | 97 ++++ .../cases/service/runs/quote-human.json | 2 +- .../service/runs/quote-linux-environment.json | 2 +- .../runs/quote-sends-hold-options.json | 7 +- .../runs/quote-url-source-rejected.json | 54 +++ ...confirmation-quote-failure-keeps-plan.json | 5 +- .../submit-confirmation-required-quote.json | 5 +- .../runs/submit-from-rev-number-pins.json | 8 +- .../runs/submit-from-rev-word-pins.json | 8 +- .../submit-preview-hidden-model-accepted.json | 8 +- .../runs/submit-preview-repo-quote.json | 7 +- .../runs/submit-preview-summary-human.json | 8 +- .../service/runs/submit-preview-summary.json | 9 +- .../runs/submit-preview-with-quote.json | 11 +- .../crates/prose-cli/tests/service_runs.rs | 2 +- .../prose-runner-core/src/service/jobs.rs | 20 +- .../prose-runner-core/src/service/mod.rs | 17 + .../src/service/program_ref.rs | 24 +- .../prose-runner-core/src/service/programs.rs | 24 +- .../prose-runner-core/src/service/runs.rs | 426 ++++++++++++++---- cli/shared/schemas/runner-error.schema.json | 1 + cli/shared/schemas/service/common.schema.json | 131 ++++++ cli/shared/schemas/service/runs.schema.json | 5 +- cli/shared/service/guide.v1.md | 14 +- cli/shared/service/help.v1.json | 8 +- cli/shared/service/operations.v1.json | 108 ++++- .../service/service-interactions.source.json | 4 +- .../service/service-interactions.v1.json | 15 + cli/shared/tests/test_service_contract.py | 11 +- docs/hosted-service-client.md | 2 +- docs/service/jobs.md | 20 +- docs/service/runs.md | 8 +- 79 files changed, 3018 insertions(+), 364 deletions(-) create mode 100644 cli/conformance/cases/service/jobs/job-create-quote-program-not-found.json create mode 100644 cli/conformance/cases/service/runs/quote-basis-extra-fields-dropped.json create mode 100644 cli/conformance/cases/service/runs/quote-basis-malformed.json create mode 100644 cli/conformance/cases/service/runs/quote-file-and-from-refused.json create mode 100644 cli/conformance/cases/service/runs/quote-file-anonymous.json create mode 100644 cli/conformance/cases/service/runs/quote-file-commit-output-needs-repo.json create mode 100644 cli/conformance/cases/service/runs/quote-file-posts-run-body.json create mode 100644 cli/conformance/cases/service/runs/quote-file-unknown-environment-rejected.json create mode 100644 cli/conformance/cases/service/runs/quote-file-unknown-runtime-rejected.json create mode 100644 cli/conformance/cases/service/runs/quote-from-own-commit-id-corrected.json create mode 100644 cli/conformance/cases/service/runs/quote-from-own-latest.json create mode 100644 cli/conformance/cases/service/runs/quote-from-own-revision-number.json create mode 100644 cli/conformance/cases/service/runs/quote-from-program-anonymous.json create mode 100644 cli/conformance/cases/service/runs/quote-from-program-basis.json create mode 100644 cli/conformance/cases/service/runs/quote-from-program-not-found.json create mode 100644 cli/conformance/cases/service/runs/quote-url-source-rejected.json diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index fcb7fd8e..d0b355f9 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -23,6 +23,15 @@ a candidate do not establish public availability or authorize publication. the help no longer call the hold flat. `run quote --model` with a model the service does not offer now returns `SERVICE_REQUEST_REJECTED` instead of being ignored. +- Quotes are program-aware: the `run submit` plan posts its exact submission + body to the quote, `run quote FILE` posts the program and `run quote --from` + names it, and the `job create` plan names its pinned `program_ref`, so the + program's own run settings and declared tools count; explicit options + override them. `run quote` reports the service's `basis` (each value and its + source). The CLI still never inspects program text. +- `sync_service_interactions.py` accepts the service's public interaction + projection (`openprose.*-interactions/2`), which carries no principals + table; the vendored catalog is re-vendored from it. - `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). diff --git a/cli/SPEC.md b/cli/SPEC.md index 2e9d8c18..5a8502d0 100644 --- a/cli/SPEC.md +++ b/cli/SPEC.md @@ -2478,14 +2478,20 @@ models, with `suggestedArgv` the same command using the nearest; a failed lookup is advisory (the service decides) except an interrupt. `program draft` 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 +and bound repositories, and the service prices a program itself, so the CLI +never inspects program text. The `run submit` plan posts its exact submission +body to `POST /run/quote` with the `POST /run` query minus `live` and +`session`. `run quote FILE` posts `{content, model, reasoning_effort, +repositories, output}` (never inputs) with the same query; `run quote --from` +sends `GET /run/quote?program_ref=` (a bare SLUG or `@N` is resolved first, as +for `run submit`) with the given `model`, `reasoning_effort`, `environment` and +`repositories=1`; without either it sends only those parameters. The +`job create` plan sends the pinned `program_ref` with the spec's `model`, +`reasoning_effort` and `environment`, plus `repositories=1` for a +`repository_url` or `context_repository_url`. Nothing is defaulted; the +program quotes send the key when one is set (private programs need it). The +`run quote` result carries the service's `basis` (each value with its source: +request, program or default) when it sends one. `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` @@ -2505,8 +2511,8 @@ the `--yes` help line reads `required because .` and `; `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 what the hold depends on, that the -quote covers only the options given, and that the price is known only after -settlement. +quote covers the program and options given (`basis` names where each came +from), 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: diff --git a/cli/bun/src/core/service/http.ts b/cli/bun/src/core/service/http.ts index a35a3779..4e81f519 100644 --- a/cli/bun/src/core/service/http.ts +++ b/cli/bun/src/core/service/http.ts @@ -63,8 +63,8 @@ export interface HoldOptions { /** * 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. + * repository sends `repositories=1`; declared tools are never sent: the CLI + * does not read the program (with a program_ref the service does). */ export function holdQuery(options: HoldOptions): Array<[string, string]> { const query: Array<[string, string]> = []; diff --git a/cli/bun/src/core/service/index.ts b/cli/bun/src/core/service/index.ts index 36ec811a..950ab81d 100644 --- a/cli/bun/src/core/service/index.ts +++ b/cli/bun/src/core/service/index.ts @@ -118,6 +118,18 @@ export class Context { return failure("SERVICE_AUTH_REQUIRED", details); } + /** + * Whether a credential is configured (the environment variable, else the OS + * store), for requests whose key is optional: they send it when there is + * one and go anonymous otherwise. An unreadable store counts as none. + */ + async credentialConfigured(): Promise { + const value = this.deps.env[this.environment.credentialEnv]; + if (value !== undefined && value !== "") return true; + try { return (await this.transport.storedCredential()) !== null; } + catch { return false; } + } + knownCredential(): string | undefined { return this.credential; } /** Where the resolved credential came from, once resolved. */ credentialOrigin(): "environment" | "store" | undefined { return this.credentialSource; } diff --git a/cli/bun/src/core/service/jobs.ts b/cli/bun/src/core/service/jobs.ts index 7ed82af8..47a8c2ab 100644 --- a/cli/bun/src/core/service/jobs.ts +++ b/cli/bun/src/core/service/jobs.ts @@ -591,13 +591,17 @@ function specString(spec: JsonObject, key: string): string | 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. + * The GET /run/quote hold for the confirmation plan, quoted for the pinned + * program the job stores (so the service applies its run settings and + * declared tools) and 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 { const repository = (key: string): boolean => specString(spec, key) !== undefined; const request = requestFor(context.operation, 0, "/run/quote"); + const programRef = specString(spec, "program_ref"); + if (programRef !== undefined) request.query.push(["program_ref", programRef]); request.query.push(...holdQuery({ model: specString(spec, "model"), reasoningEffort: specString(spec, "reasoning_effort"), diff --git a/cli/bun/src/core/service/program-ref.ts b/cli/bun/src/core/service/program-ref.ts index 5ac5d20d..fdaa1c3b 100644 --- a/cli/bun/src/core/service/program-ref.ts +++ b/cli/bun/src/core/service/program-ref.ts @@ -185,8 +185,10 @@ export async function resolveRevNumber(context: Context, reference: ProgramRef, * caller's own program must be one of its revisions (a commit id is named as * such, with the rev_id to pass), and a latest reference reads the newest * rev_id. Another owner's pinned reference is checked by the service. + * Without a `read` request (`run quote`), a bare SLUG pins the newest + * revision the caller's listing names. */ -export async function resolveToRun(context: Context, reference: ProgramRef, list: number, read: number, option: string | undefined, verifyPinned: boolean): Promise { +export async function resolveToRun(context: Context, reference: ProgramRef, list: number, read: number | undefined, option: string | undefined, verifyPinned: boolean): Promise { const given = option === undefined ? "" : context.option(option) ?? ""; if (reference.owner === "" || reference.revNumber !== undefined || (verifyPinned && reference.rev !== undefined)) { const slug = reference.slug; @@ -220,6 +222,10 @@ export async function resolveToRun(context: Context, reference: ProgramRef, list } throw noRevision(context, name, slug, rev, own.revisions); } + } else if (read === undefined && reference.rev === undefined) { + const newest = own.revisions[0]?.rev_id; + if (typeof newest !== "string" || !validRev(newest)) throw failure("SERVICE_PROTOCOL_INVALID"); + reference.rev = newest; } } else { if (reference.owner === "") throw missingOwn(context, slug, missing?.details ?? {}); @@ -231,6 +237,10 @@ export async function resolveToRun(context: Context, reference: ProgramRef, list } } } + const fixed = pinned(reference); + if (fixed !== undefined) return fixed; + // Without a read request the service resolves the latest revision itself. + if (read === undefined) return `${reference.owner}/${reference.slug}`; return (await resolveProgramRef(context, reference, read)).ref; } diff --git a/cli/bun/src/core/service/programs.ts b/cli/bun/src/core/service/programs.ts index d37088b5..ae8fc41d 100644 --- a/cli/bun/src/core/service/programs.ts +++ b/cli/bun/src/core/service/programs.ts @@ -162,10 +162,7 @@ async function list(context: Context): Promise { /** `program show` reads anonymously when no credential is configured (public programs). */ async function hasCredential(context: Context): Promise { - const value = context.deps.env[context.environment.credentialEnv]; - if (value !== undefined && value !== "") return true; - try { return (await context.transport.storedCredential()) !== null; } - catch { return false; } + return await context.credentialConfigured(); } function fileLine(label: string, file: JsonObject, target: string | undefined): string { diff --git a/cli/bun/src/core/service/runs.ts b/cli/bun/src/core/service/runs.ts index 0255492b..368dcf5e 100644 --- a/cli/bun/src/core/service/runs.ts +++ b/cli/bun/src/core/service/runs.ts @@ -235,11 +235,30 @@ function quoteFields(body: JsonObject): { hold: JsonObject } { } async function quote(context: Context): Promise { + const file = context.argument("FILE"); + const from = context.option("--from"); + checkProgramSource(file, from, false); const requested = context.option("--environment"); if (requested !== undefined && !validToken(requested)) { throw invocationFailure(`--environment ${quoteText(requested)} is not an environment id (lowercase letters, digits, _ and -)`); } + // With FILE the quote posts the fields `run submit` would send, checked the + // same way; inputs are accepted but never sent (the hold does not depend + // on them). + let posted: { body: Uint8Array; query: Array<[string, string]> } | undefined; + if (file !== undefined) { + const options = runOptions(context); + const body: JsonObject = { content: await readProgram(context, file) }; + const query = runFields(options, body); + posted = { body: new TextEncoder().encode(canonicalJson(body)), query }; + } + // `--from` names a saved program; its syntax (and `@N`) is checked here, + // before any request. + const reference: ProgramRef | undefined = from === undefined ? undefined : parseOwnAllowed(from, true); const health = jsonObject(await context.send(requestFor(context.operation, 0, "/health"))); + // With FILE, an environment or runtime the service does not offer is + // refused as `run submit` refuses it, from this same /health. + if (posted !== undefined) checkOfferedIn(context, health, requested, context.option("--runtime")); const environments = health.environments; const list = field(environments, "available"); if (!Array.isArray(list) || list.some((item) => typeof item !== "string")) throw protocol("service status environments.available is missing or malformed"); @@ -255,15 +274,31 @@ async function quote(context: Context): Promise { 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"); - 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, - })); + let request: Request; + if (posted !== undefined) { + // Request 3: the program as `POST /run` would receive it, with its query + // minus the live session; the key is sent when one is configured. + request = { ...requestFor(context.operation, 3, "/run/quote"), query: posted.query, body: posted.body, bearer: await context.credentialConfigured() }; + } else { + // Request 1 (no program) or 2 (`--from`): only the hold options given are + // sent; with `--from` they override the program's own settings. An + // OWNER/SLUG[@REV] is sent as given (the service resolves the latest); a + // bare SLUG or `@N` is the caller's own program, pinned first through its + // revisions (request 4), as `run submit --from` pins it. + request = requestFor(context.operation, reference === undefined ? 1 : 2, "/run/quote"); + if (reference !== undefined) { + const ref = reference.owner !== "" && reference.revNumber === undefined + ? from! : await resolveToRun(context, reference, 4, undefined, "--from", true); + request.query.push(["program_ref", ref]); + request.bearer = await context.credentialConfigured(); + } + 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 = ""; @@ -271,21 +306,71 @@ async function quote(context: Context): Promise { if (typeof body.note !== "string") throw protocol("quote note is malformed"); note = cleanLine(body.note, 512); } + const basis = body.basis === undefined || body.basis === null ? undefined : projectBasis(body.basis); + // The environment the service priced: the basis names it (the program's + // own setting may apply); an older service without one reports none. + if (basis !== undefined) environment = String((basis.environment as JsonObject).value); 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. Released within ${String(hold.ttl_seconds)} s when unused\n`; text += `Depends on: ${HOLD_DEPENDS_ON}; ${HOLD_COVERAGE}\n`; + if (basis !== undefined) text += `Basis: ${basisLine(basis)}\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 }; + const result: JsonObject = { environment, hold, holdBasis: HOLD_BASIS, note }; + if (basis !== undefined) result.basis = basis; + return result; } /** 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"; +/** What a quote covers: the program (FILE or --from) and the options given. */ +const HOLD_COVERAGE = "quoted for the program and options given"; /** `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`; +const HOLD_BASIS = `depends on ${HOLD_DEPENDS_ON}; ${HOLD_COVERAGE} (basis names where each came from); a run's price is known only after it settles`; + +/** Where a quote's hold input came from. */ +const BASIS_SOURCES = ["request", "program", "default"]; +/** The quote basis keys, in output order. */ +const BASIS_KEYS = ["model", "reasoning_effort", "environment", "tools", "repositories"] as const; + +/** A basis text value: 1 to 64 characters without control characters. */ +const basisText = (value: Json | undefined): value is string => typeof value === "string" && validText(value, 64); + +/** + * The closed projection of a quote's `basis`: its five keys, each projected + * to exactly `{value, source}` (other keys are dropped). A missing or + * malformed entry is SERVICE_PROTOCOL_INVALID naming the first such key. + */ +function projectBasis(value: Json): JsonObject { + if (!isObject(value)) throw protocol("quote basis is malformed"); + const out: JsonObject = {}; + for (const key of BASIS_KEYS) { + const entry = value[key]; + const item = field(entry, "value"); + const source = field(entry, "source"); + let valid: boolean; + if (key === "tools") valid = Array.isArray(item) && item.length <= 16 && item.every((tool) => basisText(tool)); + else if (key === "repositories") valid = uint(item) !== undefined; + else valid = basisText(item); + if (!valid || typeof source !== "string" || !BASIS_SOURCES.includes(source)) throw protocol(`quote basis.${key} is missing or malformed`); + out[key] = { value: Array.isArray(item) ? [...item] : item!, source }; + } + return out; +} + +/** The human `Basis:` line: each value and where it came from. */ +function basisLine(basis: JsonObject): string { + const part = (label: string, key: string): string => { + const entry = basis[key] as JsonObject; + const value = entry.value; + const shown = Array.isArray(value) + ? (value.length === 0 ? "none" : value.map((tool) => humanSafeScalar(String(tool))).join(", ")) + : humanSafeScalar(String(value)); + return `${label} ${shown} (${String(entry.source)})`; + }; + return [part("model", "model"), part("reasoning effort", "reasoning_effort"), part("environment", "environment"), part("tools", "tools"), part("repositories", "repositories")].join("; "); +} // --------------------------------------------------------------------------- // Event projection (decision 5) and terminal mapping (decision 9). @@ -884,25 +969,33 @@ const looksLikeUrl = (value: string): boolean => /^[A-Za-z][A-Za-z0-9+.-]*:\/\// interface Submission { body: Uint8Array; + /** The `POST /run` query after `live` and `session`; also the plan quote's query. */ extraQuery: Array<[string, string]>; sourceSha256: string | null; session: string | undefined; waitMs: number; - /** The `GET /run/quote` query of the plan: the hold options given. */ - quoteQuery: Array<[string, string]>; } -async function prepareSubmission(context: Context): Promise { - const file = context.argument("FILE"); - const from = context.option("--from"); +/** FILE and --from are exclusive; a URL is not a program file. `required`: one of them must be given. */ +function checkProgramSource(file: string | undefined, from: string | undefined, required: boolean): void { if (file !== undefined && from !== undefined) throw invocationFailure("give either FILE or --from OWNER/SLUG[@REV], not both"); - if (file === undefined && from === undefined) throw invocationFailure("missing program: give FILE, - for standard input, or --from OWNER/SLUG[@REV]"); + if (required && file === undefined && from === undefined) throw invocationFailure("missing program: give FILE, - for standard input, or --from OWNER/SLUG[@REV]"); if (file !== undefined && looksLikeUrl(file)) { throw invocationFailure(`URL sources are not supported (${quoteText(file)}); save the program to a file or run a saved program with --from OWNER/SLUG[@REV]`); } - const reference: ProgramRef | undefined = from === undefined ? undefined : parseOwnAllowed(from, true); - const session = sessionOption(context); - const waitMs = waitOption(context); +} + +/** The run options `run submit` sends (and `run quote FILE` quotes), checked before any request. */ +interface RunOptions { + model: string | undefined; + effort: string | undefined; + environment: string | undefined; + runtime: string | undefined; + repositories: Repository[]; + commit: Repository | undefined; +} + +function runOptions(context: Context): RunOptions { const model = context.option("--model"); if (model !== undefined && !validModel(model)) throw invocationFailure(`--model ${quoteText(model)} is not a model id; list them with \`${command(context, "model list")}\``); const effort = context.option("--reasoning-effort"); @@ -925,38 +1018,67 @@ async function prepareSubmission(context: Context): Promise { if (commit !== undefined && !repositories.some((repository) => sameRepository(repository, commit))) { throw invocationFailure(`--commit-output ${commit.owner}/${commit.name} must also be given as --repo ${commit.owner}/${commit.name}[@BRANCH]; the service commits only to a context repository`); } - const inputs = await parseInputs(context); - const body: JsonObject = {}; - let sourceSha256: string | null = null; - if (file !== undefined) { - const programText = await readText(context.cwd, file, MAX_PROGRAM_BYTES, "program file"); - if (programText.trim().length === 0) throw invocationFailure(`program file ${quoteText(file)} is empty`); - sourceSha256 = createHash("sha256").update(programText).digest("hex"); - body.content = programText; - checkParameters(programText, inputs); - } - if (inputs.size > 0) body.inputs = Object.fromEntries(inputs); - if (model !== undefined) body.model = model; - if (effort !== undefined) body.reasoning_effort = effort; - const repositoryValues: JsonObject[] = repositories.map((repository) => (repository.branch === undefined + return { model, effort, environment, runtime, repositories, commit }; +} + +/** + * Adds the run options to a submission body (model, reasoning_effort, + * repositories, output) and returns the matching query: environment, + * runtime, repositories (the same canonical array as the body) and + * output_repository, each only when given. + */ +function runFields(options: RunOptions, body: JsonObject): Array<[string, string]> { + if (options.model !== undefined) body.model = options.model; + if (options.effort !== undefined) body.reasoning_effort = options.effort; + const repositoryValues: JsonObject[] = options.repositories.map((repository) => (repository.branch === undefined ? { url: repositoryUrl(repository) } : { url: repositoryUrl(repository), branch: repository.branch })); - const extraQuery: Array<[string, string]> = []; - if (environment !== undefined) extraQuery.push(["environment", environment]); - if (runtime !== undefined) extraQuery.push(["runtime", runtime]); + const query: Array<[string, string]> = []; + if (options.environment !== undefined) query.push(["environment", options.environment]); + if (options.runtime !== undefined) query.push(["runtime", options.runtime]); if (repositoryValues.length > 0) { - extraQuery.push(["repositories", canonicalJson(repositoryValues)]); + query.push(["repositories", canonicalJson(repositoryValues)]); body.repositories = repositoryValues; } + const commit = options.commit; if (commit !== undefined) { body.output = commit.branch === undefined ? { type: "commit", repository: repositoryUrl(commit) } : { type: "commit", repository: repositoryUrl(commit), branch: commit.branch }; - extraQuery.push(["output_repository", repositoryUrl(commit)]); + query.push(["output_repository", repositoryUrl(commit)]); + } + return query; +} + +/** A program FILE (or - for standard input): at most 1 MiB of UTF-8 and not blank. */ +async function readProgram(context: Context, file: string): Promise { + const programText = await readText(context.cwd, file, MAX_PROGRAM_BYTES, "program file"); + if (programText.trim().length === 0) throw invocationFailure(`program file ${quoteText(file)} is empty`); + return programText; +} + +async function prepareSubmission(context: Context): Promise { + const file = context.argument("FILE"); + const from = context.option("--from"); + checkProgramSource(file, from, true); + const reference: ProgramRef | undefined = from === undefined ? undefined : parseOwnAllowed(from, true); + const session = sessionOption(context); + const waitMs = waitOption(context); + const options = runOptions(context); + const inputs = await parseInputs(context); + const body: JsonObject = {}; + let sourceSha256: string | null = null; + if (file !== undefined) { + const programText = await readProgram(context, file); + sourceSha256 = createHash("sha256").update(programText).digest("hex"); + body.content = programText; + checkParameters(programText, inputs); } + if (inputs.size > 0) body.inputs = Object.fromEntries(inputs); + const extraQuery = runFields(options, body); // An unknown model fails before confirmation (request 4). if (typeof body.model === "string") await context.checkModel(4, body.model); // So do an environment or runtime the service does not offer (GET /health). - await checkOffered(context, environment, runtime); + await checkOffered(context, options.environment, options.runtime); if (reference !== undefined) { // A bare `--from SLUG` is the caller's own program (manifest request 3). // A bare SLUG, `@N` and the caller's own pinned revisions resolve through @@ -968,8 +1090,7 @@ async function prepareSubmission(context: Context): Promise { 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`); } - const quoteQuery = holdQuery({ model, reasoningEffort: effort, environment, repositoriesBound: repositories.length > 0 || commit !== undefined }); - return { body: bytes, extraQuery, sourceSha256, session, waitMs, quoteQuery }; + return { body: bytes, extraQuery, sourceSha256, session, waitMs }; } /** @@ -987,6 +1108,11 @@ async function checkOffered(context: Context, environment: string | undefined, r if (caught instanceof RunnerFailure && caught.code !== "CANCELLED") return; throw caught; } + checkOfferedIn(context, health, environment, runtime); +} + +/** `checkOffered` against a /health body already read; a list that is missing or empty checks nothing. */ +function checkOfferedIn(context: Context, health: JsonObject, environment: string | undefined, runtime: string | undefined): void { for (const [option, key, value] of [["--environment", "environments", environment], ["--runtime", "runtimes", runtime]] as const) { if (value === undefined) continue; const list = field(field(health, key), "available"); @@ -1061,8 +1187,9 @@ async function submit(context: Context): Promise { if (context.invocation.preview || !context.invocation.yes) { 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" }; - quoteRequest.query.push(...submission.quoteQuery); + // The plan's quote prices exactly this submission: the same body bytes + // and the `POST /run` query without the live session. + const quoteRequest: Request = { ...requestFor(context.operation, 1, "/run/quote"), class: "control", query: [...submission.extraQuery], body: submission.body }; // The quote is advisory: a failed quote never hides the plan. try { const { hold } = quoteFields(jsonObject(await context.send(quoteRequest))); @@ -1444,7 +1571,7 @@ function endedResult(context: Context, runId: string, runStatus: string | null): return { runId, status: "already_ended", runStatus }; } -export const __test = { cleanLine, cleanText, terminalText, unrecognizedName, parseWait, parseAfter, projectEvent, projectTerminal, validRunId }; +export const __test = { cleanLine, cleanText, terminalText, unrecognizedName, parseWait, parseAfter, projectEvent, projectTerminal, validRunId, projectBasis, basisLine }; /** A human `[agent]` message: line breaks stay line breaks, each continuation line indented under the tag (mirrors Rust `agent_message`). */ function agentMessage(message: Json | undefined): string { diff --git a/cli/bun/test/service-runs.test.ts b/cli/bun/test/service-runs.test.ts index 9eed8028..918cf35c 100644 --- a/cli/bun/test/service-runs.test.ts +++ b/cli/bun/test/service-runs.test.ts @@ -87,6 +87,32 @@ describe("Service run lifecycle", () => { expect(problem.details.reason).toBe("--input notes file \"big.txt\" is larger than 1048576 bytes"); }); + test("a quote basis is projected only when well formed", () => { + const entry = (value: unknown, source = "program") => ({ value, source }); + const basis = { model: entry("model-luna"), reasoning_effort: entry("medium"), environment: entry("builtin", "default"), tools: entry([], "default"), repositories: entry(2, "request") }; + expect(__test.projectBasis(basis as never) as unknown).toEqual(basis); + expect(__test.basisLine(__test.projectBasis(basis as never))).toBe("model model-luna (program); reasoning effort medium (program); environment builtin (default); tools none (default); repositories 2 (request)"); + expect(__test.projectBasis({ ...basis, extra: entry("x"), model: { ...entry("model-luna"), note: "x" } } as never) as unknown).toEqual(basis); + const malformed = [ + { ...basis, model: undefined }, + { ...basis, model: { value: "model-luna" } }, + { ...basis, model: entry("") }, + { ...basis, model: entry("x".repeat(65)) }, + { ...basis, environment: entry("built\nin") }, + { ...basis, tools: entry(Array.from({ length: 17 }, (_, index) => `tool-${index}`)) }, + { ...basis, repositories: entry(-1) }, + { ...basis, repositories: entry(1.5) }, + { ...basis, reasoning_effort: entry("medium", "service") }, + ]; + for (const value of malformed) expect(() => __test.projectBasis(value as never)).toThrow(); + const reason = (value: unknown): unknown => { + try { __test.projectBasis(value as never); } catch (caught) { return (caught as { details?: { reason?: unknown } }).details?.reason; } + return undefined; + }; + expect(reason([])).toBe("quote basis is malformed"); + expect(reason({ ...basis, tools: entry("web:search"), repositories: entry(-1) })).toBe("quote basis.tools is missing or malformed"); + }); + test("a program file over 1 MiB is rejected before any request", async () => { const result = await cli(submit("big.prose.md", "--yes"), (home) => { writeFileSync(join(home, "big.prose.md"), "x".repeat(MEBIBYTE + 1)); @@ -99,7 +125,7 @@ describe("Service run lifecycle", () => { const result = await cli(submit("p.prose.md", "--input", "notes=@max.txt", "--preview"), (home) => { writeFileSync(join(home, "p.prose.md"), "Reply ok.\n"); writeFileSync(join(home, "max.txt"), "y".repeat(MEBIBYTE)); - }, { environment: "production", exchanges: [{ method: "GET", path: "/run/quote", status: 200, body: { hold: { hold_usd: "1.02", ttl_seconds: 900 }, pricing_policy_id: "p" } }] }); + }, { environment: "production", exchanges: [{ method: "POST", path: "/run/quote", status: 200, body: { hold: { hold_usd: "1.02", ttl_seconds: 900 }, pricing_policy_id: "p" } }] }); expect(result.code).toBe(0); const planned = JSON.parse(result.stdout).result.plannedRequest; expect(planned.bodyBytes).toBeGreaterThan(MEBIBYTE); diff --git a/cli/ci/sync_service_interactions.py b/cli/ci/sync_service_interactions.py index 9111a885..ba5689e2 100644 --- a/cli/ci/sync_service_interactions.py +++ b/cli/ci/sync_service_interactions.py @@ -5,8 +5,10 @@ python3 cli/ci/sync_service_interactions.py --check [--from EXPORT_FILE] EXPORT_FILE is the OpenProse service's interaction export: a JSON object -with `schema` (an `openprose.*-interactions/1` id), -`principals` and `interactions`, where each interaction has an `id`, a +with `schema` (an `openprose.*-interactions/1` id), `principals` and +`interactions`. The service's public projection (`openprose.*-interactions/2`) +is also accepted; it lists only allowlisted interactions and carries no +`principals` table. In both, each interaction has an `id`, a `principal`, an `effect`, `reversible`, an `agent` policy and its `routes` (`method`, `path`, `auth`). The export is checked against that public format, then projected before it is written to @@ -45,7 +47,10 @@ SCHEMA = "openprose.service-interactions/1" # An export names its own interactions schema; the projection always uses SCHEMA. -EXPORT_SCHEMA = re.compile(r"^openprose\.[a-z0-9-]+-interactions/1$") +EXPORT_SCHEMA = re.compile(r"^openprose\.[a-z0-9-]+-interactions/[12]$") +# The public projection (/2) publishes no principals table; its interactions +# must still use only the public principals. +PUBLIC_EXPORT_SCHEMA = re.compile(r"^openprose\.[a-z0-9-]+-interactions/2$") # The public export format: the fields this client reads. An export must # carry them; the vendored projection carries exactly them. TOP_KEYS = {"schema", "principals", "interactions"} @@ -115,7 +120,11 @@ def validate_interactions(doc: dict, exact: bool) -> dict: def validate_export(data: bytes) -> dict: """Checks an export against the public format. Raises SyncError on any violation.""" - return validate_interactions(parse(data), exact=False) + doc = parse(data) + schema = doc.get("schema") + if isinstance(schema, str) and PUBLIC_EXPORT_SCHEMA.match(schema) and "principals" not in doc: + doc = dict(doc, principals=sorted(PRINCIPALS)) + return validate_interactions(doc, exact=False) def manifest_usage(manifest: dict) -> tuple[set[str], set[tuple[str, str, str, str]]]: diff --git a/cli/conformance/cases/service/framework/help-json-group-records.json b/cli/conformance/cases/service/framework/help-json-group-records.json index 6c15ba62..979ebb23 100644 --- a/cli/conformance/cases/service/framework/help-json-group-records.json +++ b/cli/conformance/cases/service/framework/help-json-group-records.json @@ -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 quote does not read the program, so the program's own run settings and declared tools are not included.", + "description": "Program file, or - for standard input, as `cli run submit` takes it. The service prices this program, including its own run settings and declared tools.", "name": "FILE", "required": false, "variadic": false @@ -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 quote does not read the program, so the program's own run settings and declared tools are not included.", + "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 service prices the latest revision. The service prices this program, including its own run settings and declared tools.", "name": "--from", "repeatable": false, "required": false, @@ -96,7 +96,7 @@ }, { "default": "the service's default_model (`cli model list`)", - "description": "Hosted model id (see `cli model list`). Sent with the quote: the hold depends on it.", + "description": "Hosted model id (see `cli model list`). Sent with the quote; overrides the program's own setting.", "name": "--model", "repeatable": false, "required": false, @@ -110,7 +110,7 @@ "high", "xhigh" ], - "description": "Reasoning effort supported by the model. Sent with the quote: the hold depends on it.", + "description": "Reasoning effort supported by the model. Sent with the quote; overrides the program's own setting.", "name": "--reasoning-effort", "repeatable": false, "required": false, @@ -118,7 +118,7 @@ }, { "default": "the service's default environment", - "description": "Execution environment offered by the service (for example builtin or linux). Sent with the quote: the hold depends on it.", + "description": "Execution environment offered by the service (for example builtin or linux). Sent with the quote; overrides the program's own setting.", "name": "--environment", "repeatable": false, "required": false, @@ -133,14 +133,14 @@ "value": "RUNTIME" }, { - "description": "Read-only repository context. Any --repo or --commit-output sends repositories=1 with the quote: a bound repository adds to the hold.", + "description": "Read-only repository context. Sent 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. Any --repo or --commit-output sends repositories=1 with the quote: a bound repository adds to the hold.", + "description": "Writable repository that receives the run's commit. Sent with the quote: a bound repository adds to the hold.", "name": "--commit-output", "repeatable": false, "required": false, @@ -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 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." + "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. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). 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": [ diff --git a/cli/conformance/cases/service/framework/service-capabilities-json.json b/cli/conformance/cases/service/framework/service-capabilities-json.json index bd4e2d3b..e66f5429 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-json.json +++ b/cli/conformance/cases/service/framework/service-capabilities-json.json @@ -203,7 +203,7 @@ "--json" ], "schema": "openprose.service-operations/1", - "sha256": "c3aef4f39d4fa26f6af3334e311806c5aedca0cb9934c47c325678467454edd5" + "sha256": "87e68b5f8f2e0125f7f6afc548a2d888bdd9c645261c59935fd4e3e7dc388bbb" }, "nouns": { "auth": [ @@ -442,7 +442,7 @@ "prose cli run quote hello.prose.md --input topic=cats --json" ], "id": "run.quote", - "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." + "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. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). 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." }, { "command": [ diff --git a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json index e2b1e202..bb1804d6 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json +++ b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json @@ -209,7 +209,7 @@ "--json" ], "schema": "openprose.service-operations/1", - "sha256": "c3aef4f39d4fa26f6af3334e311806c5aedca0cb9934c47c325678467454edd5" + "sha256": "87e68b5f8f2e0125f7f6afc548a2d888bdd9c645261c59935fd4e3e7dc388bbb" }, "nouns": { "auth": [ @@ -448,7 +448,7 @@ "prose cli run quote hello.prose.md --input topic=cats --json" ], "id": "run.quote", - "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." + "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. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). 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." }, { "command": [ diff --git a/cli/conformance/cases/service/framework/service-guide-human.json b/cli/conformance/cases/service/framework/service-guide-human.json index eca9c2f5..e9f2be59 100644 --- a/cli/conformance/cases/service/framework/service-guide-human.json +++ b/cli/conformance/cases/service/framework/service-guide-human.json @@ -13,7 +13,7 @@ }, "exitCode": 0, "stdout": { - "text": "# OpenProse service guide\n\n## Start here\n\n`prose cli` commands reach the hosted OpenProse service. They never\nprompt and never run anything locally. This guide is the workflow view;\nthe per-command facts come from these commands:\n\n- `prose cli service triage --json`: where this session stands, in one call:\n health, whether the key works (or the variable to set), balance, default\n organization, recent runs, jobs and `nextCommands`. Each section has its\n own `problem`; it exits 0 whenever it produces the report.\n- `prose cli service capabilities --json`: grammar, exit code of every error\n code, environment variables and every command with examples, on one line.\n- `prose cli service operations --json`: every command with its arguments,\n options and effect, as JSON.\n- `prose cli --help`: one command, ending with its exit codes.\n- `prose cli service guide --json`: this guide as\n `{\"sections\":[{\"id\",\"title\",\"body\"}]}` in the usual envelope.\n\nProgram files (`.prose.md`) are not described here: read a working one with\n`prose cli example list` and `prose cli example show NAME`. New to the\nservice? Start with \"Your first program\" below, then \"Run it every day\",\n\"Scripting\", \"What will it cost\", \"Headless and CI keys\" and \"Sharing\".\n\n## Your first program\n\nStart from a working example, run it once, then save it.\n\n```sh\nprose cli example list\nprose cli example show NAME --output-file hello.prose.md\nprose cli run submit hello.prose.md --preview\nprose cli run submit hello.prose.md --yes\nprose cli program save hello hello.prose.md\n```\n\n1. `cli example list` prints the example names. `cli example show` with\n `--output-file` writes one to a new file; edit it with any editor.\n2. `--preview` shows what the run would do and what it would reserve, and\n sends nothing. `--yes` starts the paid run and streams its answer.\n3. `cli program save SLUG FILE` keeps it as your own private program. Run\n the saved program with `prose cli run submit --from hello --yes`.\n\nTools a run can use depend on its environment (`--environment`): `builtin`\nhas file tools only; `linux` adds a shell, without network access. A\nprogram reaches the web through the `browser:interact` tool.\n\n## Run it every day\n\nA job runs a saved program on its own. A schedule job repeats every\n`interval_seconds` (60 to 2678400; 86400 is once a day). The first run\nstarts about one second after the job is created. There is no cron\nexpression or time of day yet.\n\n```sh\nprose cli program save hello hello.prose.md --json\nprose cli program show hello --json\nprintf '%s' '{\"type\":\"schedule\",\"program_ref\":\"OWNER/hello@REV\",\"interval_seconds\":86400}' > daily.json\nprose cli job create --spec-file daily.json --preview\nprose cli job create --spec-file daily.json --yes --json\nprose cli job list\nprose cli job delete JOB_ID --yes\n```\n\n`program_ref` is the pinned reference `result.program.ref` that\n`program save` and `program show` print (`OWNER/SLUG@REV`). Each run is paid\nlike any other. `cli job list` shows the next run time; `cli job delete`\nstops the schedule for good.\n\nTo run at a fixed time of day instead, let the operating system's scheduler\n(cron, launchd) run one command with `OPENPROSE_API_KEY` set:\n\n```sh\nprose cli run submit --from OWNER/hello --yes --json | jq .result.run.response\n```\n\n## Scripting\n\n- Pass `--json` and read `result`; on an error, `problem.code` and\n `problem.details`. Every exit code is in the table in \"Exit codes, resume\n and detach\"; a script branches on it with `case $?`.\n- The run id is `result.runId` (`result.run.run_id` once the run ends).\n- For a long run, submit with `--detach` (exit 0 as soon as the run id is\n known), then follow it with `prose cli run watch RUN_ID --wait 10m --json`.\n If `--wait` passes first, the exit is 21 and `problem.details.resumeArgv`\n is the command to run next. Never submit again to resume.\n- `cli run list` and `cli wallet events` return one page. Pass\n `result.nextBefore` to `--before` for the next one; it is `null` on the\n last page.\n\n```sh\nprose cli run submit --from OWNER/hello --yes --detach --json > run.json\nprose cli run watch RUN_ID --wait 10m --json\nprose cli run list --limit 50 --before CURSOR --json\n```\n\n## What will it cost\n\nA run reserves a hold before it starts: money set aside from the wallet,\nnot the price. The hold depends on the model, reasoning effort, environment,\ndeclared tools and bound repositories. The price is known only after the run\nsettles: `price_cents` in `prose cli run show RUN_ID --json`, of which\n`environment_price_cents` is the environment's share. What the run did not\nuse is released. Short runs usually cost a few cents.\n\nEstimate from your own history: `prose cli wallet usage` prints runs and\nprices by day, and `prose cli wallet balance` shows what is available and\nwhat is reserved right now. `prose cli run quote --json` reports the hold for\nthe `--model`, `--reasoning-effort`, `--environment`, `--repo` and\n`--commit-output` you give it. It does not read the program, so a program\nthat sets its own model, effort or environment, or declares tools, can hold a\ndifferent amount.\n\nPremium models unlock with any wallet top-up; `prose cli model list` shows\neach model's status.\n\n## Headless and CI keys\n\nA machine without a person at a browser uses an API key in\n`OPENPROSE_API_KEY`; it wins over the stored key. Keep it in the CI system's\nsecret store and set it only for the commands that need it.\n`prose cli auth login` stores its key in the operating system credential\nstore, which a launchd or cron job cannot reach without a login session:\nset `OPENPROSE_API_KEY` in the job instead. Check a key with\n`prose cli auth status --json`.\n\n## Sharing\n\n`prose cli run share RUN_ID --yes` creates a public link to a run's outputs.\nAnyone with the link can open it for 24 hours, and it cannot be revoked. To\ngive specific people access, invite them to your organization instead:\n`prose cli org invite --help`.\n\n## Grammar\n\n```text\nprose [--output human|json|jsonl] cli [ARGUMENTS] [OPTIONS]\n```\n\n- Every service command starts with `cli`. Without it (`run submit` or\n `wallet balance` right after `prose`) the command exits 2 and\n `details.suggestedArgv` names the `cli` command.\n- Global options go before `cli`; command options go after the command path.\n `--output` may also follow the command path.\n A command option before `cli` (`--json`, `--yes`, `--limit 5`) exits 2 and\n `details.suggestedArgv` moves it after the command path.\n- `--json` is `--output json`: one JSON object on one line. Streaming\n commands (`cli run submit`, `cli run watch`) print one event per line with\n `--output jsonl` and end with exactly one `service.completed`,\n `service.failed` or `service.detached` line (`service.detached` is exit 21:\n the run continues). List commands (`cli run list`, `cli job list`,\n `cli model list`, ...) print one `openprose.service-record/1` line per item\n with `--output jsonl`, then one `openprose.service-page/1` line with\n `count`, `nextBefore` and the rest of the result in `meta`. That last line\n carries `exitCode`, the process exit code. Every JSON line has its keys\n sorted, so the same response prints the same bytes.\n- Times the service sends as epoch milliseconds (`createdAt`, `nextFireAt`,\n `updated_at`, ...) also appear as `_iso` (RFC 3339 UTC). Human output\n shows money in dollars and ends with `Next:` commands you can copy.\n- Nothing is guessed. A misspelled or foreign word exits 2\n (`INVOCATION_INVALID`); `details.suggestedArgv` is the corrected argv (the\n words after `prose`). Rerun it; it keeps your output mode. A few exact\n spellings are aliases and run their command: `cli run status` and\n `cli run get` are `cli run show`, `cli run logs`, `cli run tail` and\n `cli run wait` are `cli run watch`, `cli run create` and `cli run start` are\n `cli run submit`, `cli program push` and `cli program upload` are\n `cli program save`, and `cli program rm` and `cli job rm` are `delete` (still\n with `--yes`). The manifest lists them in `grammar.intentInference.commandAliases`.\n A suggestion is a complete command that parses: a group gains its verb\n (`cli model list`), a dollar amount becomes cents (`--amount-cents 500`),\n and an out-of-range `--limit` becomes the nearest accepted value. When the\n fix reads standard input, `details.suggestedStdin` holds what to pipe in;\n a secret (a credit code, an invitation token) is never echoed, so pipe\n your own.\n\n## Credentials\n\nThe key, first match wins:\n\n1. `OPENPROSE_API_KEY`, when set and non-empty;\n2. the key `prose cli auth login` stored in the operating-system credential\n store (login needs a person at a browser).\n\nA variable always wins over the stored key, so a bad variable must be fixed\nor unset. `prose cli auth status --json` reports `credentialSource`.\n`SERVICE_AUTH_REQUIRED` names `details.credentialVariable` and\n`details.credentialProblem` (`missing`, `malformed` or `rejected`), and its\nAction follows where the key came from: an environment key is replaced or\nunset, never fixed by `cli auth login`. Nothing ever falls back to another\nkey. The output mode is `--json` or `--output`, then `PROSE_OUTPUT`, then\nhuman.\n\n## Confirm and preview\n\nA command that spends money, publishes, deletes or cannot be undone needs\n`--yes`. Without it nothing is changed: it exits 2 with `CONFIRMATION_REQUIRED`,\n`details.reason` (what `--yes` would do, for example that rotating a secret\ninvalidates the old one now), `details.plannedRequest` (the method, what the\nrequest does as `description`, its non-secret inputs as `summary`: model, programRef, inputKeys,\namount_cents, slug; and for `cli run submit`, `cli program draft` and a paid\n`cli job create` the service's hold quote for the options or spec given, and\nfor `cli program draft` the parameter-free quote),\n`details.confirmArgv` and\n`details.previewArgv`. `--preview` prints the same plan, exits 0 and changes\nnothing. An unknown `--model`, an `--environment` or `--runtime` that\n`prose cli service status --json` does not list (`environments`), an input the\nprogram's `Parameters` section does not declare (or a declared one that is\nmissing and not marked optional), an invalid organization slug or a job spec\nthe service would reject fails here, before any confirmation, with every\nproblem listed:\n\n```sh\nprose cli run submit hello.prose.md --preview\nprose cli run submit hello.prose.md --yes\n```\n\n`prose cli service operations` shows which commands need `--yes` (CONFIRM).\n\n## Exit codes, resume and detach\n\n| Exit | Meaning | What a script does |\n| --- | --- | --- |\n| 0 | success | read `result` |\n| 2 | `INVOCATION_INVALID` or `CONFIRMATION_REQUIRED`; nothing was sent | rerun `details.suggestedArgv`, or add `--yes` on purpose |\n| 10 | service or credential error | read `problem.code` and `details.reason`; retry only when `problem.retryable` is true |\n| 20 | this installation cannot run the command | reinstall the CLI; retrying will not help |\n| 21 | detached: the run continues | run `details.resumeArgv`; never submit again |\n| 22 | the run failed, or the submission is ambiguous | read `details.reason`; if ambiguous, rerun `details.resumeArgv` (it reuses the same session, never a new run) |\n| 24 | cancelled | stop; nothing to resume |\n\nIn JSON mode the error is `problem` (`code`, `retryable`, `action`,\n`details`), with `result` null. This holds for every `cli` command line,\nincluding a near miss or a misplaced flag: then `operation` is `cli` if the\nargv names no operation. Only the language and the commands for this machine\n(listed by the top-level help) print a bare runner error. The exit of every\ncode is in\n`prose cli service capabilities --json` (`exitCodes`).\n\nExit 21 means the CLI stopped following a run that keeps running:\n`--wait` passed (`SERVICE_WATCH_DEADLINE`) or Ctrl-C or a lost stream\n(`HOSTED_RUN_DETACHED`); both are not retryable and carry\n`details.resumable: true`. A `--wait` that passes before the run id arrives\nkeeps reading until the id is known, so it is still exit 21 with the run\nnamed. A successful `--detach` exits 0 with `result.detached`,\n`result.resumeArgv`, `result.cancelArgv` and `result.run` as\n`{run_id, status}`. Results carry the run id as `result.run_id` and\n`result.runId`.\nResume with `resumeArgv` (a `cli run watch` command with `--session`). Never resume by\nsubmitting again: a new submit is a second paid run. Only\n`prose cli run cancel RUN_ID --yes` cancels; on a run that already ended it\nexits 0 with `result.status` `already_ended`. Steering an ended run with\n`cli run input` is not retryable: read it with `details.suggestedArgv`.\n\n`SERVICE_RESOURCE_NOT_FOUND` (10) is not retryable. `details.resource`\nnames the kind and id, and `details.suggestedArgv` lists the ones that\nexist: run it and retry with an id it prints. A well-formed run id the\nservice does not know (`cli run show`, `cli run watch`) and a `--file` the\nrun does not list are this error. A run id with fewer than 64 hex digits was\ncut off when copied: it exits 2 before any request. `latest` in place of a\nrun id is your newest run (`cli run show latest`). `program delete` and\n`job delete` with `--yes` of something already gone exit 0 with\n`result.alreadyAbsent`.\n\n`RUN_SUBMISSION_AMBIGUOUS` (22) means the connection was lost before the\nrun id was known, and the CLI did not repeat the submission. Rerun\n`details.resumeArgv`: the same `cli run submit` with the same `--session`\nand `--detach`. A session never starts a second run: `run submit --session S`\nof a session that already has a run (in this machine's journal, or as the\nservice reports) returns that run with `result.reused: true` and exit 0.\n`prose cli run list --limit 5 --json` (`details.suggestedArgv`) lists recent\nruns.\n\nA run its owner stopped has `cancelled: true` in `cli run show` and\n`cli run list`, and `cli run watch` of it exits 24.\n\n## Paging\n\n`cli run list` and `cli wallet events` are paged. Pass\n`result.nextBefore` to `--before` for the next page; it is `null` on the last\npage.\nThe cursor is opaque. Human mode prints a `Next page:` line with the full\ncommand, and `--output jsonl` carries `nextBefore` in the page line.\n\n```sh\nprose cli run list --limit 50 --json\nprose cli run list --limit 50 --before CURSOR --json\n```\n\n`cli result list` takes `--limit` only (at most 100) and has no cursor.\n\n## Get a run's answer\n\n- While it runs: human mode prints the program's text on stdout. With\n `--json`, the answer is `result.run.response`; `result.run.files` lists the\n files it wrote.\n- After a detach (runs submitted from this machine): replay the whole stream\n with `prose cli run watch RUN_ID --json` and read `result.run.response`.\n For a run started elsewhere, the same command reports an ended run's\n status and files from its record (`result.source` is `record`, no\n response text).\n- Any run, from anywhere: `prose cli run show RUN_ID --json` gives status,\n files and price but never the response text. `result.run.response` is the\n run's text as written, trailing whitespace included. Read a file with\n `prose cli run show RUN_ID --file outputs/result.json`, or fetch them all\n into `./RUN_ID` with `prose cli run download RUN_ID` (or `--output-dir DIR`).\n- A result someone published (`OWNER/SLUG`) is not a run record:\n `prose cli result show OWNER/SLUG --latest --json`.\n\n## Recipes\n\n### Submit and wait\n\n```sh\nprose cli run quote --json\nprose cli run submit hello.prose.md --input topic=otters --preview\nprose cli run submit hello.prose.md --input topic=otters --yes --json > run.json\njq -r '.result.run.response' run.json\n```\n\n`cli run quote` is the wallet hold a run reserves, not its price; the settled\nprice is `result.run.price_cents`. A saved program runs with\n`--from SLUG` (your own) or `--from OWNER/SLUG@REV`; every program record\n(`program list|show|save|revisions --json`) carries that pinned reference as\n`ref`, and `prose cli program show SLUG@1 --json` resolves a revision number\nof your own program to it. On exit 21, rerun\n`problem.details.resumeArgv`; for a long run submit with `--detach` and follow\nwith `prose cli run watch RUN_ID --wait 10m --json`.\n\n### Publish a result\n\nA published result is a completed run of a saved revision of a public\nprogram that wrote `outputs/result.json`.\n\n```sh\nprose cli program save haiku haiku.prose.md --json\nprose cli run submit --from haiku --yes --json\nprose cli program visibility haiku public --preview\nprose cli result publish haiku --run RUN_ID --yes --json\nprose cli result show haiku --json\n```\n\nMaking a program public exposes its source; the CLI never adds `--yes` to that\nstep for you. Reading published results needs no key; a bare `SLUG` (your\nown program) is resolved with your key first, and without a publication id\n`result show` reads the newest.\n\n### Create a webhook job\n\nA job starts runs on its own. The spec is snake_case JSON; a test-mode webhook\nreceives events and never starts runs.\n\n```sh\nprintf '%s' '{\"type\":\"webhook\",\"name\":\"my-hook\",\"delivery_mode\":\"test\"}' > hook.json\nprose cli job create --spec-file hook.json --preview\nprose cli job create --spec-file hook.json --yes --json\nprose cli job deliveries JOB_ID --json\n```\n\n`result.endpoint` and `result.signing_secret` appear only in the create result\n(and in `cli job rotate-secret`); store them then. `result.endpointUrl` is the\nabsolute URL to configure in the sender; `cli job show` repeats it. `prose cli job create --help`\nlists the schedule and webhook spec keys; `prose cli job list --json` lists the\njob types. The keys each type accepts are the `spec` of `job.create` in\n`prose cli service operations --json`; a spec is checked against it before any\nrequest and every problem is reported at once in `details.violations`. A\nwebhook with no `program_ref` starts no runs, so its plan has effect `write`\nand no hold.\n" + "text": "# OpenProse service guide\n\n## Start here\n\n`prose cli` commands reach the hosted OpenProse service. They never\nprompt and never run anything locally. This guide is the workflow view;\nthe per-command facts come from these commands:\n\n- `prose cli service triage --json`: where this session stands, in one call:\n health, whether the key works (or the variable to set), balance, default\n organization, recent runs, jobs and `nextCommands`. Each section has its\n own `problem`; it exits 0 whenever it produces the report.\n- `prose cli service capabilities --json`: grammar, exit code of every error\n code, environment variables and every command with examples, on one line.\n- `prose cli service operations --json`: every command with its arguments,\n options and effect, as JSON.\n- `prose cli --help`: one command, ending with its exit codes.\n- `prose cli service guide --json`: this guide as\n `{\"sections\":[{\"id\",\"title\",\"body\"}]}` in the usual envelope.\n\nProgram files (`.prose.md`) are not described here: read a working one with\n`prose cli example list` and `prose cli example show NAME`. New to the\nservice? Start with \"Your first program\" below, then \"Run it every day\",\n\"Scripting\", \"What will it cost\", \"Headless and CI keys\" and \"Sharing\".\n\n## Your first program\n\nStart from a working example, run it once, then save it.\n\n```sh\nprose cli example list\nprose cli example show NAME --output-file hello.prose.md\nprose cli run submit hello.prose.md --preview\nprose cli run submit hello.prose.md --yes\nprose cli program save hello hello.prose.md\n```\n\n1. `cli example list` prints the example names. `cli example show` with\n `--output-file` writes one to a new file; edit it with any editor.\n2. `--preview` shows what the run would do and what it would reserve, and\n sends nothing. `--yes` starts the paid run and streams its answer.\n3. `cli program save SLUG FILE` keeps it as your own private program. Run\n the saved program with `prose cli run submit --from hello --yes`.\n\nTools a run can use depend on its environment (`--environment`): `builtin`\nhas file tools only; `linux` adds a shell, without network access. A\nprogram reaches the web through the `browser:interact` tool.\n\n## Run it every day\n\nA job runs a saved program on its own. A schedule job repeats every\n`interval_seconds` (60 to 2678400; 86400 is once a day). The first run\nstarts about one second after the job is created. There is no cron\nexpression or time of day yet.\n\n```sh\nprose cli program save hello hello.prose.md --json\nprose cli program show hello --json\nprintf '%s' '{\"type\":\"schedule\",\"program_ref\":\"OWNER/hello@REV\",\"interval_seconds\":86400}' > daily.json\nprose cli job create --spec-file daily.json --preview\nprose cli job create --spec-file daily.json --yes --json\nprose cli job list\nprose cli job delete JOB_ID --yes\n```\n\n`program_ref` is the pinned reference `result.program.ref` that\n`program save` and `program show` print (`OWNER/SLUG@REV`). Each run is paid\nlike any other. `cli job list` shows the next run time; `cli job delete`\nstops the schedule for good.\n\nTo run at a fixed time of day instead, let the operating system's scheduler\n(cron, launchd) run one command with `OPENPROSE_API_KEY` set:\n\n```sh\nprose cli run submit --from OWNER/hello --yes --json | jq .result.run.response\n```\n\n## Scripting\n\n- Pass `--json` and read `result`; on an error, `problem.code` and\n `problem.details`. Every exit code is in the table in \"Exit codes, resume\n and detach\"; a script branches on it with `case $?`.\n- The run id is `result.runId` (`result.run.run_id` once the run ends).\n- For a long run, submit with `--detach` (exit 0 as soon as the run id is\n known), then follow it with `prose cli run watch RUN_ID --wait 10m --json`.\n If `--wait` passes first, the exit is 21 and `problem.details.resumeArgv`\n is the command to run next. Never submit again to resume.\n- `cli run list` and `cli wallet events` return one page. Pass\n `result.nextBefore` to `--before` for the next one; it is `null` on the\n last page.\n\n```sh\nprose cli run submit --from OWNER/hello --yes --detach --json > run.json\nprose cli run watch RUN_ID --wait 10m --json\nprose cli run list --limit 50 --before CURSOR --json\n```\n\n## What will it cost\n\nA run reserves a hold before it starts: money set aside from the wallet,\nnot the price. The hold depends on the model, reasoning effort, environment,\ndeclared tools and bound repositories. The price is known only after the run\nsettles: `price_cents` in `prose cli run show RUN_ID --json`, of which\n`environment_price_cents` is the environment's share. What the run did not\nuse is released. Short runs usually cost a few cents.\n\nEstimate from your own history: `prose cli wallet usage` prints runs and\nprices by day, and `prose cli wallet balance` shows what is available and\nwhat is reserved right now. `prose cli run quote FILE --json` (or `--from\nOWNER/SLUG`) reports the hold for that program, including its own run\nsettings and declared tools; `--model`, `--reasoning-effort`, `--environment`,\n`--repo` and `--commit-output` override them. `result.basis` names where each\nvalue came from (request, program or default).\n\nPremium models unlock with any wallet top-up; `prose cli model list` shows\neach model's status.\n\n## Headless and CI keys\n\nA machine without a person at a browser uses an API key in\n`OPENPROSE_API_KEY`; it wins over the stored key. Keep it in the CI system's\nsecret store and set it only for the commands that need it.\n`prose cli auth login` stores its key in the operating system credential\nstore, which a launchd or cron job cannot reach without a login session:\nset `OPENPROSE_API_KEY` in the job instead. Check a key with\n`prose cli auth status --json`.\n\n## Sharing\n\n`prose cli run share RUN_ID --yes` creates a public link to a run's outputs.\nAnyone with the link can open it for 24 hours, and it cannot be revoked. To\ngive specific people access, invite them to your organization instead:\n`prose cli org invite --help`.\n\n## Grammar\n\n```text\nprose [--output human|json|jsonl] cli [ARGUMENTS] [OPTIONS]\n```\n\n- Every service command starts with `cli`. Without it (`run submit` or\n `wallet balance` right after `prose`) the command exits 2 and\n `details.suggestedArgv` names the `cli` command.\n- Global options go before `cli`; command options go after the command path.\n `--output` may also follow the command path.\n A command option before `cli` (`--json`, `--yes`, `--limit 5`) exits 2 and\n `details.suggestedArgv` moves it after the command path.\n- `--json` is `--output json`: one JSON object on one line. Streaming\n commands (`cli run submit`, `cli run watch`) print one event per line with\n `--output jsonl` and end with exactly one `service.completed`,\n `service.failed` or `service.detached` line (`service.detached` is exit 21:\n the run continues). List commands (`cli run list`, `cli job list`,\n `cli model list`, ...) print one `openprose.service-record/1` line per item\n with `--output jsonl`, then one `openprose.service-page/1` line with\n `count`, `nextBefore` and the rest of the result in `meta`. That last line\n carries `exitCode`, the process exit code. Every JSON line has its keys\n sorted, so the same response prints the same bytes.\n- Times the service sends as epoch milliseconds (`createdAt`, `nextFireAt`,\n `updated_at`, ...) also appear as `_iso` (RFC 3339 UTC). Human output\n shows money in dollars and ends with `Next:` commands you can copy.\n- Nothing is guessed. A misspelled or foreign word exits 2\n (`INVOCATION_INVALID`); `details.suggestedArgv` is the corrected argv (the\n words after `prose`). Rerun it; it keeps your output mode. A few exact\n spellings are aliases and run their command: `cli run status` and\n `cli run get` are `cli run show`, `cli run logs`, `cli run tail` and\n `cli run wait` are `cli run watch`, `cli run create` and `cli run start` are\n `cli run submit`, `cli program push` and `cli program upload` are\n `cli program save`, and `cli program rm` and `cli job rm` are `delete` (still\n with `--yes`). The manifest lists them in `grammar.intentInference.commandAliases`.\n A suggestion is a complete command that parses: a group gains its verb\n (`cli model list`), a dollar amount becomes cents (`--amount-cents 500`),\n and an out-of-range `--limit` becomes the nearest accepted value. When the\n fix reads standard input, `details.suggestedStdin` holds what to pipe in;\n a secret (a credit code, an invitation token) is never echoed, so pipe\n your own.\n\n## Credentials\n\nThe key, first match wins:\n\n1. `OPENPROSE_API_KEY`, when set and non-empty;\n2. the key `prose cli auth login` stored in the operating-system credential\n store (login needs a person at a browser).\n\nA variable always wins over the stored key, so a bad variable must be fixed\nor unset. `prose cli auth status --json` reports `credentialSource`.\n`SERVICE_AUTH_REQUIRED` names `details.credentialVariable` and\n`details.credentialProblem` (`missing`, `malformed` or `rejected`), and its\nAction follows where the key came from: an environment key is replaced or\nunset, never fixed by `cli auth login`. Nothing ever falls back to another\nkey. The output mode is `--json` or `--output`, then `PROSE_OUTPUT`, then\nhuman.\n\n## Confirm and preview\n\nA command that spends money, publishes, deletes or cannot be undone needs\n`--yes`. Without it nothing is changed: it exits 2 with `CONFIRMATION_REQUIRED`,\n`details.reason` (what `--yes` would do, for example that rotating a secret\ninvalidates the old one now), `details.plannedRequest` (the method, what the\nrequest does as `description`, its non-secret inputs as `summary`: model, programRef, inputKeys,\namount_cents, slug; and for `cli run submit`, `cli program draft` and a paid\n`cli job create` the service's hold quote for that program and the options or\nspec given, and for `cli program draft` the parameter-free quote),\n`details.confirmArgv` and\n`details.previewArgv`. `--preview` prints the same plan, exits 0 and changes\nnothing. An unknown `--model`, an `--environment` or `--runtime` that\n`prose cli service status --json` does not list (`environments`), an input the\nprogram's `Parameters` section does not declare (or a declared one that is\nmissing and not marked optional), an invalid organization slug or a job spec\nthe service would reject fails here, before any confirmation, with every\nproblem listed:\n\n```sh\nprose cli run submit hello.prose.md --preview\nprose cli run submit hello.prose.md --yes\n```\n\n`prose cli service operations` shows which commands need `--yes` (CONFIRM).\n\n## Exit codes, resume and detach\n\n| Exit | Meaning | What a script does |\n| --- | --- | --- |\n| 0 | success | read `result` |\n| 2 | `INVOCATION_INVALID` or `CONFIRMATION_REQUIRED`; nothing was sent | rerun `details.suggestedArgv`, or add `--yes` on purpose |\n| 10 | service or credential error | read `problem.code` and `details.reason`; retry only when `problem.retryable` is true |\n| 20 | this installation cannot run the command | reinstall the CLI; retrying will not help |\n| 21 | detached: the run continues | run `details.resumeArgv`; never submit again |\n| 22 | the run failed, or the submission is ambiguous | read `details.reason`; if ambiguous, rerun `details.resumeArgv` (it reuses the same session, never a new run) |\n| 24 | cancelled | stop; nothing to resume |\n\nIn JSON mode the error is `problem` (`code`, `retryable`, `action`,\n`details`), with `result` null. This holds for every `cli` command line,\nincluding a near miss or a misplaced flag: then `operation` is `cli` if the\nargv names no operation. Only the language and the commands for this machine\n(listed by the top-level help) print a bare runner error. The exit of every\ncode is in\n`prose cli service capabilities --json` (`exitCodes`).\n\nExit 21 means the CLI stopped following a run that keeps running:\n`--wait` passed (`SERVICE_WATCH_DEADLINE`) or Ctrl-C or a lost stream\n(`HOSTED_RUN_DETACHED`); both are not retryable and carry\n`details.resumable: true`. A `--wait` that passes before the run id arrives\nkeeps reading until the id is known, so it is still exit 21 with the run\nnamed. A successful `--detach` exits 0 with `result.detached`,\n`result.resumeArgv`, `result.cancelArgv` and `result.run` as\n`{run_id, status}`. Results carry the run id as `result.run_id` and\n`result.runId`.\nResume with `resumeArgv` (a `cli run watch` command with `--session`). Never resume by\nsubmitting again: a new submit is a second paid run. Only\n`prose cli run cancel RUN_ID --yes` cancels; on a run that already ended it\nexits 0 with `result.status` `already_ended`. Steering an ended run with\n`cli run input` is not retryable: read it with `details.suggestedArgv`.\n\n`SERVICE_RESOURCE_NOT_FOUND` (10) is not retryable. `details.resource`\nnames the kind and id, and `details.suggestedArgv` lists the ones that\nexist: run it and retry with an id it prints. A well-formed run id the\nservice does not know (`cli run show`, `cli run watch`) and a `--file` the\nrun does not list are this error. A run id with fewer than 64 hex digits was\ncut off when copied: it exits 2 before any request. `latest` in place of a\nrun id is your newest run (`cli run show latest`). `program delete` and\n`job delete` with `--yes` of something already gone exit 0 with\n`result.alreadyAbsent`.\n\n`RUN_SUBMISSION_AMBIGUOUS` (22) means the connection was lost before the\nrun id was known, and the CLI did not repeat the submission. Rerun\n`details.resumeArgv`: the same `cli run submit` with the same `--session`\nand `--detach`. A session never starts a second run: `run submit --session S`\nof a session that already has a run (in this machine's journal, or as the\nservice reports) returns that run with `result.reused: true` and exit 0.\n`prose cli run list --limit 5 --json` (`details.suggestedArgv`) lists recent\nruns.\n\nA run its owner stopped has `cancelled: true` in `cli run show` and\n`cli run list`, and `cli run watch` of it exits 24.\n\n## Paging\n\n`cli run list` and `cli wallet events` are paged. Pass\n`result.nextBefore` to `--before` for the next page; it is `null` on the last\npage.\nThe cursor is opaque. Human mode prints a `Next page:` line with the full\ncommand, and `--output jsonl` carries `nextBefore` in the page line.\n\n```sh\nprose cli run list --limit 50 --json\nprose cli run list --limit 50 --before CURSOR --json\n```\n\n`cli result list` takes `--limit` only (at most 100) and has no cursor.\n\n## Get a run's answer\n\n- While it runs: human mode prints the program's text on stdout. With\n `--json`, the answer is `result.run.response`; `result.run.files` lists the\n files it wrote.\n- After a detach (runs submitted from this machine): replay the whole stream\n with `prose cli run watch RUN_ID --json` and read `result.run.response`.\n For a run started elsewhere, the same command reports an ended run's\n status and files from its record (`result.source` is `record`, no\n response text).\n- Any run, from anywhere: `prose cli run show RUN_ID --json` gives status,\n files and price but never the response text. `result.run.response` is the\n run's text as written, trailing whitespace included. Read a file with\n `prose cli run show RUN_ID --file outputs/result.json`, or fetch them all\n into `./RUN_ID` with `prose cli run download RUN_ID` (or `--output-dir DIR`).\n- A result someone published (`OWNER/SLUG`) is not a run record:\n `prose cli result show OWNER/SLUG --latest --json`.\n\n## Recipes\n\n### Submit and wait\n\n```sh\nprose cli run quote --json\nprose cli run submit hello.prose.md --input topic=otters --preview\nprose cli run submit hello.prose.md --input topic=otters --yes --json > run.json\njq -r '.result.run.response' run.json\n```\n\n`cli run quote` is the wallet hold a run reserves, not its price; the settled\nprice is `result.run.price_cents`. A saved program runs with\n`--from SLUG` (your own) or `--from OWNER/SLUG@REV`; every program record\n(`program list|show|save|revisions --json`) carries that pinned reference as\n`ref`, and `prose cli program show SLUG@1 --json` resolves a revision number\nof your own program to it. On exit 21, rerun\n`problem.details.resumeArgv`; for a long run submit with `--detach` and follow\nwith `prose cli run watch RUN_ID --wait 10m --json`.\n\n### Publish a result\n\nA published result is a completed run of a saved revision of a public\nprogram that wrote `outputs/result.json`.\n\n```sh\nprose cli program save haiku haiku.prose.md --json\nprose cli run submit --from haiku --yes --json\nprose cli program visibility haiku public --preview\nprose cli result publish haiku --run RUN_ID --yes --json\nprose cli result show haiku --json\n```\n\nMaking a program public exposes its source; the CLI never adds `--yes` to that\nstep for you. Reading published results needs no key; a bare `SLUG` (your\nown program) is resolved with your key first, and without a publication id\n`result show` reads the newest.\n\n### Create a webhook job\n\nA job starts runs on its own. The spec is snake_case JSON; a test-mode webhook\nreceives events and never starts runs.\n\n```sh\nprintf '%s' '{\"type\":\"webhook\",\"name\":\"my-hook\",\"delivery_mode\":\"test\"}' > hook.json\nprose cli job create --spec-file hook.json --preview\nprose cli job create --spec-file hook.json --yes --json\nprose cli job deliveries JOB_ID --json\n```\n\n`result.endpoint` and `result.signing_secret` appear only in the create result\n(and in `cli job rotate-secret`); store them then. `result.endpointUrl` is the\nabsolute URL to configure in the sender; `cli job show` repeats it. `prose cli job create --help`\nlists the schedule and webhook spec keys; `prose cli job list --json` lists the\njob types. The keys each type accepts are the `spec` of `job.create` in\n`prose cli service operations --json`; a spec is checked against it before any\nrequest and every problem is reported at once in `details.violations`. A\nwebhook with no `program_ref` starts no runs, so its plan has effect `write`\nand no hold.\n" }, "stderr": { "text": "" diff --git a/cli/conformance/cases/service/framework/service-guide-json.json b/cli/conformance/cases/service/framework/service-guide-json.json index 298e52d7..b905f968 100644 --- a/cli/conformance/cases/service/framework/service-guide-json.json +++ b/cli/conformance/cases/service/framework/service-guide-json.json @@ -43,7 +43,7 @@ { "id": "what-will-it-cost", "title": "What will it cost", - "body": "A run reserves a hold before it starts: money set aside from the wallet,\nnot the price. The hold depends on the model, reasoning effort, environment,\ndeclared tools and bound repositories. The price is known only after the run\nsettles: `price_cents` in `prose cli run show RUN_ID --json`, of which\n`environment_price_cents` is the environment's share. What the run did not\nuse is released. Short runs usually cost a few cents.\n\nEstimate from your own history: `prose cli wallet usage` prints runs and\nprices by day, and `prose cli wallet balance` shows what is available and\nwhat is reserved right now. `prose cli run quote --json` reports the hold for\nthe `--model`, `--reasoning-effort`, `--environment`, `--repo` and\n`--commit-output` you give it. It does not read the program, so a program\nthat sets its own model, effort or environment, or declares tools, can hold a\ndifferent amount.\n\nPremium models unlock with any wallet top-up; `prose cli model list` shows\neach model's status." + "body": "A run reserves a hold before it starts: money set aside from the wallet,\nnot the price. The hold depends on the model, reasoning effort, environment,\ndeclared tools and bound repositories. The price is known only after the run\nsettles: `price_cents` in `prose cli run show RUN_ID --json`, of which\n`environment_price_cents` is the environment's share. What the run did not\nuse is released. Short runs usually cost a few cents.\n\nEstimate from your own history: `prose cli wallet usage` prints runs and\nprices by day, and `prose cli wallet balance` shows what is available and\nwhat is reserved right now. `prose cli run quote FILE --json` (or `--from\nOWNER/SLUG`) reports the hold for that program, including its own run\nsettings and declared tools; `--model`, `--reasoning-effort`, `--environment`,\n`--repo` and `--commit-output` override them. `result.basis` names where each\nvalue came from (request, program or default).\n\nPremium models unlock with any wallet top-up; `prose cli model list` shows\neach model's status." }, { "id": "headless-and-ci-keys", @@ -68,7 +68,7 @@ { "id": "confirm-and-preview", "title": "Confirm and preview", - "body": "A command that spends money, publishes, deletes or cannot be undone needs\n`--yes`. Without it nothing is changed: it exits 2 with `CONFIRMATION_REQUIRED`,\n`details.reason` (what `--yes` would do, for example that rotating a secret\ninvalidates the old one now), `details.plannedRequest` (the method, what the\nrequest does as `description`, its non-secret inputs as `summary`: model, programRef, inputKeys,\namount_cents, slug; and for `cli run submit`, `cli program draft` and a paid\n`cli job create` the service's hold quote for the options or spec given, and\nfor `cli program draft` the parameter-free quote),\n`details.confirmArgv` and\n`details.previewArgv`. `--preview` prints the same plan, exits 0 and changes\nnothing. An unknown `--model`, an `--environment` or `--runtime` that\n`prose cli service status --json` does not list (`environments`), an input the\nprogram's `Parameters` section does not declare (or a declared one that is\nmissing and not marked optional), an invalid organization slug or a job spec\nthe service would reject fails here, before any confirmation, with every\nproblem listed:\n\n```sh\nprose cli run submit hello.prose.md --preview\nprose cli run submit hello.prose.md --yes\n```\n\n`prose cli service operations` shows which commands need `--yes` (CONFIRM)." + "body": "A command that spends money, publishes, deletes or cannot be undone needs\n`--yes`. Without it nothing is changed: it exits 2 with `CONFIRMATION_REQUIRED`,\n`details.reason` (what `--yes` would do, for example that rotating a secret\ninvalidates the old one now), `details.plannedRequest` (the method, what the\nrequest does as `description`, its non-secret inputs as `summary`: model, programRef, inputKeys,\namount_cents, slug; and for `cli run submit`, `cli program draft` and a paid\n`cli job create` the service's hold quote for that program and the options or\nspec given, and for `cli program draft` the parameter-free quote),\n`details.confirmArgv` and\n`details.previewArgv`. `--preview` prints the same plan, exits 0 and changes\nnothing. An unknown `--model`, an `--environment` or `--runtime` that\n`prose cli service status --json` does not list (`environments`), an input the\nprogram's `Parameters` section does not declare (or a declared one that is\nmissing and not marked optional), an invalid organization slug or a job spec\nthe service would reject fails here, before any confirmation, with every\nproblem listed:\n\n```sh\nprose cli run submit hello.prose.md --preview\nprose cli run submit hello.prose.md --yes\n```\n\n`prose cli service operations` shows which commands need `--yes` (CONFIRM)." }, { "id": "exit-codes-resume-and-detach", diff --git a/cli/conformance/cases/service/framework/service-guide-jsonl.json b/cli/conformance/cases/service/framework/service-guide-jsonl.json index dff4d82a..9ff52a6a 100644 --- a/cli/conformance/cases/service/framework/service-guide-jsonl.json +++ b/cli/conformance/cases/service/framework/service-guide-jsonl.json @@ -49,7 +49,7 @@ { "id": "what-will-it-cost", "title": "What will it cost", - "body": "A run reserves a hold before it starts: money set aside from the wallet,\nnot the price. The hold depends on the model, reasoning effort, environment,\ndeclared tools and bound repositories. The price is known only after the run\nsettles: `price_cents` in `prose cli run show RUN_ID --json`, of which\n`environment_price_cents` is the environment's share. What the run did not\nuse is released. Short runs usually cost a few cents.\n\nEstimate from your own history: `prose cli wallet usage` prints runs and\nprices by day, and `prose cli wallet balance` shows what is available and\nwhat is reserved right now. `prose cli run quote --json` reports the hold for\nthe `--model`, `--reasoning-effort`, `--environment`, `--repo` and\n`--commit-output` you give it. It does not read the program, so a program\nthat sets its own model, effort or environment, or declares tools, can hold a\ndifferent amount.\n\nPremium models unlock with any wallet top-up; `prose cli model list` shows\neach model's status." + "body": "A run reserves a hold before it starts: money set aside from the wallet,\nnot the price. The hold depends on the model, reasoning effort, environment,\ndeclared tools and bound repositories. The price is known only after the run\nsettles: `price_cents` in `prose cli run show RUN_ID --json`, of which\n`environment_price_cents` is the environment's share. What the run did not\nuse is released. Short runs usually cost a few cents.\n\nEstimate from your own history: `prose cli wallet usage` prints runs and\nprices by day, and `prose cli wallet balance` shows what is available and\nwhat is reserved right now. `prose cli run quote FILE --json` (or `--from\nOWNER/SLUG`) reports the hold for that program, including its own run\nsettings and declared tools; `--model`, `--reasoning-effort`, `--environment`,\n`--repo` and `--commit-output` override them. `result.basis` names where each\nvalue came from (request, program or default).\n\nPremium models unlock with any wallet top-up; `prose cli model list` shows\neach model's status." }, { "id": "headless-and-ci-keys", @@ -74,7 +74,7 @@ { "id": "confirm-and-preview", "title": "Confirm and preview", - "body": "A command that spends money, publishes, deletes or cannot be undone needs\n`--yes`. Without it nothing is changed: it exits 2 with `CONFIRMATION_REQUIRED`,\n`details.reason` (what `--yes` would do, for example that rotating a secret\ninvalidates the old one now), `details.plannedRequest` (the method, what the\nrequest does as `description`, its non-secret inputs as `summary`: model, programRef, inputKeys,\namount_cents, slug; and for `cli run submit`, `cli program draft` and a paid\n`cli job create` the service's hold quote for the options or spec given, and\nfor `cli program draft` the parameter-free quote),\n`details.confirmArgv` and\n`details.previewArgv`. `--preview` prints the same plan, exits 0 and changes\nnothing. An unknown `--model`, an `--environment` or `--runtime` that\n`prose cli service status --json` does not list (`environments`), an input the\nprogram's `Parameters` section does not declare (or a declared one that is\nmissing and not marked optional), an invalid organization slug or a job spec\nthe service would reject fails here, before any confirmation, with every\nproblem listed:\n\n```sh\nprose cli run submit hello.prose.md --preview\nprose cli run submit hello.prose.md --yes\n```\n\n`prose cli service operations` shows which commands need `--yes` (CONFIRM)." + "body": "A command that spends money, publishes, deletes or cannot be undone needs\n`--yes`. Without it nothing is changed: it exits 2 with `CONFIRMATION_REQUIRED`,\n`details.reason` (what `--yes` would do, for example that rotating a secret\ninvalidates the old one now), `details.plannedRequest` (the method, what the\nrequest does as `description`, its non-secret inputs as `summary`: model, programRef, inputKeys,\namount_cents, slug; and for `cli run submit`, `cli program draft` and a paid\n`cli job create` the service's hold quote for that program and the options or\nspec given, and for `cli program draft` the parameter-free quote),\n`details.confirmArgv` and\n`details.previewArgv`. `--preview` prints the same plan, exits 0 and changes\nnothing. An unknown `--model`, an `--environment` or `--runtime` that\n`prose cli service status --json` does not list (`environments`), an input the\nprogram's `Parameters` section does not declare (or a declared one that is\nmissing and not marked optional), an invalid organization slug or a job spec\nthe service would reject fails here, before any confirmation, with every\nproblem listed:\n\n```sh\nprose cli run submit hello.prose.md --preview\nprose cli run submit hello.prose.md --yes\n```\n\n`prose cli service operations` shows which commands need `--yes` (CONFIRM)." }, { "id": "exit-codes-resume-and-detach", diff --git a/cli/conformance/cases/service/jobs/job-create-bare-slug-pins.json b/cli/conformance/cases/service/jobs/job-create-bare-slug-pins.json index 43e42733..b0177462 100644 --- a/cli/conformance/cases/service/jobs/job-create-bare-slug-pins.json +++ b/cli/conformance/cases/service/jobs/job-create-bare-slug-pins.json @@ -75,8 +75,14 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "job_type": "schedule" }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, "status": 200, "body": { "hold": { @@ -85,11 +91,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "requestHeaders": { - "Authorization": { - "present": false - } } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-confirmation-required-quote.json b/cli/conformance/cases/service/jobs/job-create-confirmation-required-quote.json index 16376dcc..19e377f8 100644 --- a/cli/conformance/cases/service/jobs/job-create-confirmation-required-quote.json +++ b/cli/conformance/cases/service/jobs/job-create-confirmation-required-quote.json @@ -29,8 +29,14 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "job_type": "schedule" }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, "status": 200, "body": { "hold": { @@ -39,11 +45,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "requestHeaders": { - "Authorization": { - "present": false - } } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-preview-human.json b/cli/conformance/cases/service/jobs/job-create-preview-human.json index 4f778ea4..be47c05a 100644 --- a/cli/conformance/cases/service/jobs/job-create-preview-human.json +++ b/cli/conformance/cases/service/jobs/job-create-preview-human.json @@ -28,8 +28,14 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "job_type": "schedule" }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, "status": 200, "body": { "hold": { @@ -38,11 +44,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "requestHeaders": { - "Authorization": { - "present": false - } } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-preview-quote.json b/cli/conformance/cases/service/jobs/job-create-preview-quote.json index fa9603b3..e9642125 100644 --- a/cli/conformance/cases/service/jobs/job-create-preview-quote.json +++ b/cli/conformance/cases/service/jobs/job-create-preview-quote.json @@ -30,8 +30,14 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "job_type": "schedule" }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, "status": 200, "body": { "hold": { @@ -40,11 +46,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "requestHeaders": { - "Authorization": { - "present": false - } } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-quote-program-not-found.json b/cli/conformance/cases/service/jobs/job-create-quote-program-not-found.json new file mode 100644 index 00000000..22ef0fe8 --- /dev/null +++ b/cli/conformance/cases/service/jobs/job-create-quote-program-not-found.json @@ -0,0 +1,73 @@ +{ + "id": "job-create-quote-program-not-found", + "feature": "jobs", + "operation": "job.create", + "description": "A job's program the service cannot read is SERVICE_RESOURCE_NOT_FOUND from the plan's quote; no job is created.", + "argv": [ + "--output", + "json", + "cli", + "job", + "create", + "--spec-file", + "spec.json", + "--preview" + ], + "files": [ + { + "path": "spec.json", + "content": "{\"type\": \"schedule\", \"program_ref\": \"exowner1/jobs-example@0123456789abcdef\", \"interval_seconds\": 86400}\n" + } + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 404, + "body": { + "error": "Program not found.", + "code": "program_not_found" + } + } + ] + }, + "exitCode": 10, + "stdout": { + "json": { + "interaction": "job.deploy", + "operation": "job.create", + "problem": { + "action": "Check the identifier, then retry.", + "boundary": "hosted-service", + "code": "SERVICE_RESOURCE_NOT_FOUND", + "details": { + "serviceCode": "program_not_found", + "serviceStatus": 404 + }, + "exitCode": 10, + "message": "The requested OpenProse service resource was not found.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/jobs/job-create-quote-unavailable.json b/cli/conformance/cases/service/jobs/job-create-quote-unavailable.json index f3f8d3c0..96530263 100644 --- a/cli/conformance/cases/service/jobs/job-create-quote-unavailable.json +++ b/cli/conformance/cases/service/jobs/job-create-quote-unavailable.json @@ -29,16 +29,17 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "job_type": "schedule" }, - "status": 500, - "body": { - "error_code": 1101 - }, "requestHeaders": { "Authorization": { - "present": false + "present": true } + }, + "status": 500, + "body": { + "error_code": 1101 } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-rev-number-human.json b/cli/conformance/cases/service/jobs/job-create-rev-number-human.json index 306f4752..b5022a5f 100644 --- a/cli/conformance/cases/service/jobs/job-create-rev-number-human.json +++ b/cli/conformance/cases/service/jobs/job-create-rev-number-human.json @@ -55,8 +55,14 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "job_type": "schedule" }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, "status": 200, "body": { "hold": { @@ -65,11 +71,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "requestHeaders": { - "Authorization": { - "present": false - } } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-rev-number-pins.json b/cli/conformance/cases/service/jobs/job-create-rev-number-pins.json index cc251aa8..fb9b759b 100644 --- a/cli/conformance/cases/service/jobs/job-create-rev-number-pins.json +++ b/cli/conformance/cases/service/jobs/job-create-rev-number-pins.json @@ -57,8 +57,14 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@fedcba9876543210", "job_type": "schedule" }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, "status": 200, "body": { "hold": { @@ -67,11 +73,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "requestHeaders": { - "Authorization": { - "present": false - } } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-schedule-empty-strings-quote.json b/cli/conformance/cases/service/jobs/job-create-schedule-empty-strings-quote.json index 3052f3a5..6ab7e95f 100644 --- a/cli/conformance/cases/service/jobs/job-create-schedule-empty-strings-quote.json +++ b/cli/conformance/cases/service/jobs/job-create-schedule-empty-strings-quote.json @@ -2,7 +2,7 @@ "id": "job-create-schedule-empty-strings-quote", "feature": "jobs", "operation": "job.create", - "description": "Empty spec strings are not hold options: a schedule with an empty model, environment and repository_url quotes with no parameters.", + "description": "Empty spec strings are not hold options: a schedule with an empty model, reasoning_effort, environment and repository_url quotes with no parameters.", "argv": [ "--output", "json", @@ -30,8 +30,14 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "job_type": "schedule" }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, "status": 200, "body": { "hold": { @@ -40,11 +46,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "requestHeaders": { - "Authorization": { - "present": false - } } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-schedule-environment-quote.json b/cli/conformance/cases/service/jobs/job-create-schedule-environment-quote.json index cd1a68e1..5c7fa96b 100644 --- a/cli/conformance/cases/service/jobs/job-create-schedule-environment-quote.json +++ b/cli/conformance/cases/service/jobs/job-create-schedule-environment-quote.json @@ -30,10 +30,16 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "environment": "linux", "repositories": "1", "job_type": "schedule" }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, "status": 200, "body": { "hold": { @@ -42,11 +48,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "requestHeaders": { - "Authorization": { - "present": false - } } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-unpinned-ref.json b/cli/conformance/cases/service/jobs/job-create-unpinned-ref.json index 38fc15ab..992d1d72 100644 --- a/cli/conformance/cases/service/jobs/job-create-unpinned-ref.json +++ b/cli/conformance/cases/service/jobs/job-create-unpinned-ref.json @@ -48,8 +48,14 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "job_type": "schedule" }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, "status": 200, "body": { "hold": { @@ -58,11 +64,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "requestHeaders": { - "Authorization": { - "present": false - } } } ] diff --git a/cli/conformance/cases/service/jobs/job-create-webhook-binding-quote.json b/cli/conformance/cases/service/jobs/job-create-webhook-binding-quote.json index 721365b0..2c33e0c1 100644 --- a/cli/conformance/cases/service/jobs/job-create-webhook-binding-quote.json +++ b/cli/conformance/cases/service/jobs/job-create-webhook-binding-quote.json @@ -30,6 +30,7 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "model": "model-sol", "reasoning_effort": "high", "repositories": "1", @@ -37,7 +38,7 @@ }, "requestHeaders": { "Authorization": { - "present": false + "present": true } }, "status": 200, diff --git a/cli/conformance/cases/service/jobs/job-create-webhook-with-program-quotes.json b/cli/conformance/cases/service/jobs/job-create-webhook-with-program-quotes.json index 3a20c146..2399ca9d 100644 --- a/cli/conformance/cases/service/jobs/job-create-webhook-with-program-quotes.json +++ b/cli/conformance/cases/service/jobs/job-create-webhook-with-program-quotes.json @@ -30,11 +30,12 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "exowner1/jobs-example@0123456789abcdef", "job_type": "webhook" }, "requestHeaders": { "Authorization": { - "present": false + "present": true } }, "status": 200, diff --git a/cli/conformance/cases/service/runs/alias-run-create-previews.json b/cli/conformance/cases/service/runs/alias-run-create-previews.json index 300c7376..6db44c87 100644 --- a/cli/conformance/cases/service/runs/alias-run-create-previews.json +++ b/cli/conformance/cases/service/runs/alias-run-create-previews.json @@ -50,16 +50,14 @@ } }, { - "method": "GET", + "method": "POST", "path": "/run/quote", - "query": { - "model": "model-luna" - }, "requestHeaders": { "Authorization": { - "present": false + "present": true } }, + "expectedSha256": "98955150e7e049b9cd674891bb034679fdbb791ba8aec1bd3cb17bbd5df780f8", "status": 200, "body": { "hold": { diff --git a/cli/conformance/cases/service/runs/quote-accepts-submit-arguments.json b/cli/conformance/cases/service/runs/quote-accepts-submit-arguments.json index d99931e7..a6a240b6 100644 --- a/cli/conformance/cases/service/runs/quote-accepts-submit-arguments.json +++ b/cli/conformance/cases/service/runs/quote-accepts-submit-arguments.json @@ -2,7 +2,7 @@ "id": "quote-accepts-submit-arguments", "feature": "runs", "operation": "run.quote", - "description": "Quote takes the program, inputs and options `cli run submit` takes, so the quoted command is the one to submit. --model is sent with the quote; the program and inputs are not.", + "description": "With FILE, `run quote` posts the program and the hold options to the quote, as `run submit` would send them; inputs are not sent. Human output names each basis value and its source.", "argv": [ "cli", "run", @@ -48,16 +48,17 @@ } }, { - "method": "GET", + "method": "POST", "path": "/run/quote", - "query": { - "model": "model-sol" - }, "requestHeaders": { "Authorization": { - "present": false + "present": true } }, + "expectedBody": { + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n", + "model": "model-sol" + }, "status": 200, "body": { "hold": { @@ -65,14 +66,38 @@ "ttl_seconds": 900 }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", - "note": "Unused hold is released when the run settles." + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-sol", + "source": "request" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "builtin", + "source": "default" + }, + "tools": { + "value": [ + "web:search" + ], + "source": "program" + }, + "repositories": { + "value": 0, + "source": "default" + } + } } } ] }, "exitCode": 0, "stdout": { - "text": "Environment: builtin\nHold: $1.02, set aside from the wallet while a run is live; not its price. Released within 900 s when unused\nDepends on: model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools\nPrice: known only after a run settles; read it with `prose cli run show RUN_ID`\nNote: Unused hold is released when the run settles.\n" + "text": "Environment: builtin\nHold: $1.02, set aside from the wallet while a run is live; not its price. Released within 900 s when unused\nDepends on: model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given\nBasis: model model-sol (request); reasoning effort medium (program); environment builtin (default); tools web:search (program); repositories 0 (default)\nPrice: known only after a run settles; read it with `prose cli run show RUN_ID`\nNote: Unused hold is released when the run settles.\n" }, "stderr": { "text": "" @@ -80,5 +105,11 @@ "forbid": [ "9.9.9-extra", "web_search" + ], + "files": [ + { + "path": "hello.prose.md", + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n" + } ] } diff --git a/cli/conformance/cases/service/runs/quote-basis-extra-fields-dropped.json b/cli/conformance/cases/service/runs/quote-basis-extra-fields-dropped.json new file mode 100644 index 00000000..8ae23ab4 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-basis-extra-fields-dropped.json @@ -0,0 +1,151 @@ +{ + "id": "quote-basis-extra-fields-dropped", + "feature": "runs", + "operation": "run.quote", + "description": "Fields the service adds inside `basis` beyond value and source, or beyond the five inputs, are dropped from the result.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "exowner1/hello" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/hello" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-luna", + "source": "program", + "catalog_note": "extra" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "builtin", + "source": "default" + }, + "tools": { + "value": [ + "web:search" + ], + "source": "program" + }, + "repositories": { + "value": 0, + "source": "default" + }, + "future_input": { + "value": 1, + "source": "default" + } + } + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": null, + "result": { + "basis": { + "environment": { + "source": "default", + "value": "builtin" + }, + "model": { + "source": "program", + "value": "model-luna" + }, + "reasoning_effort": { + "source": "program", + "value": "medium" + }, + "repositories": { + "source": "default", + "value": 0 + }, + "tools": { + "source": "program", + "value": [ + "web:search" + ] + } + }, + "environment": "builtin", + "hold": { + "hold_cents": 102, + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", + "note": "Unused hold is released when the run settles." + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-basis-malformed.json b/cli/conformance/cases/service/runs/quote-basis-malformed.json new file mode 100644 index 00000000..b35b9af5 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-basis-malformed.json @@ -0,0 +1,122 @@ +{ + "id": "quote-basis-malformed", + "feature": "runs", + "operation": "run.quote", + "description": "A malformed basis is SERVICE_PROTOCOL_INVALID.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "exowner1/hello" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/hello" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-luna", + "source": "program" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "builtin", + "source": "default" + }, + "tools": { + "value": "web:search", + "source": "program" + }, + "repositories": { + "value": 0, + "source": "default" + } + } + } + } + ] + }, + "exitCode": 10, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": { + "action": "Retry later and report the sanitized error code if it persists.", + "boundary": "protocol", + "code": "SERVICE_PROTOCOL_INVALID", + "details": { + "reason": "quote basis.tools is missing or malformed" + }, + "exitCode": 10, + "message": "The OpenProse service returned an invalid response.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-commit-output-only.json b/cli/conformance/cases/service/runs/quote-commit-output-only.json index 7b5141a2..dd3bd4c2 100644 --- a/cli/conformance/cases/service/runs/quote-commit-output-only.json +++ b/cli/conformance/cases/service/runs/quote-commit-output-only.json @@ -82,7 +82,7 @@ "hold_usd": "1.02", "ttl_seconds": 900 }, - "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools; a run's price is known only after it settles", + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", "note": "Unused hold is released when the run settles." }, "schema": "openprose.service-operation/1" diff --git a/cli/conformance/cases/service/runs/quote-default-environment-unknown-fields.json b/cli/conformance/cases/service/runs/quote-default-environment-unknown-fields.json index 68b08a57..67eb7442 100644 --- a/cli/conformance/cases/service/runs/quote-default-environment-unknown-fields.json +++ b/cli/conformance/cases/service/runs/quote-default-environment-unknown-fields.json @@ -147,7 +147,7 @@ "hold_usd": "1.02", "ttl_seconds": 900 }, - "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools; a run's price is known only after it settles", + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", "note": "Unused hold is released when the run settles." }, "schema": "openprose.service-operation/1" diff --git a/cli/conformance/cases/service/runs/quote-default-environment.json b/cli/conformance/cases/service/runs/quote-default-environment.json index 860abe9a..da0793a2 100644 --- a/cli/conformance/cases/service/runs/quote-default-environment.json +++ b/cli/conformance/cases/service/runs/quote-default-environment.json @@ -77,7 +77,7 @@ "hold_usd": "1.02", "ttl_seconds": 900 }, - "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools; a run's price is known only after it settles", + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", "note": "Unused hold is released when the run settles." }, "schema": "openprose.service-operation/1" diff --git a/cli/conformance/cases/service/runs/quote-file-and-from-refused.json b/cli/conformance/cases/service/runs/quote-file-and-from-refused.json new file mode 100644 index 00000000..2590a309 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-file-and-from-refused.json @@ -0,0 +1,62 @@ +{ + "id": "quote-file-and-from-refused", + "feature": "runs", + "operation": "run.quote", + "description": "`run quote` refuses FILE and --from together, as `run submit` does, before any request.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "p.prose.md", + "--from", + "exowner1/hello" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli run quote --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "give either FILE or --from OWNER/SLUG[@REV], not both", + "suggestedArgv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "files": [ + { + "path": "p.prose.md", + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n" + } + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-file-anonymous.json b/cli/conformance/cases/service/runs/quote-file-anonymous.json new file mode 100644 index 00000000..986b3869 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-file-anonymous.json @@ -0,0 +1,147 @@ +{ + "id": "quote-file-anonymous", + "feature": "runs", + "operation": "run.quote", + "description": "Without a key, `run quote FILE` posts the program anonymously.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "p.prose.md" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": null + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "POST", + "path": "/run/quote", + "requestHeaders": { + "Authorization": { + "present": false + } + }, + "expectedBody": { + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n" + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-luna", + "source": "program" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "builtin", + "source": "default" + }, + "tools": { + "value": [], + "source": "default" + }, + "repositories": { + "value": 0, + "source": "default" + } + } + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": null, + "result": { + "basis": { + "environment": { + "source": "default", + "value": "builtin" + }, + "model": { + "source": "program", + "value": "model-luna" + }, + "reasoning_effort": { + "source": "program", + "value": "medium" + }, + "repositories": { + "source": "default", + "value": 0 + }, + "tools": { + "source": "default", + "value": [] + } + }, + "environment": "builtin", + "hold": { + "hold_cents": 102, + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", + "note": "Unused hold is released when the run settles." + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + }, + "files": [ + { + "path": "p.prose.md", + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n" + } + ] +} diff --git a/cli/conformance/cases/service/runs/quote-file-commit-output-needs-repo.json b/cli/conformance/cases/service/runs/quote-file-commit-output-needs-repo.json new file mode 100644 index 00000000..c7a2c147 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-file-commit-output-needs-repo.json @@ -0,0 +1,66 @@ +{ + "id": "quote-file-commit-output-needs-repo", + "feature": "runs", + "operation": "run.quote", + "description": "With FILE, `run quote` applies `run submit`'s rule that --commit-output must also be a --repo, before any request.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "p.prose.md", + "--commit-output", + "exowner1/context" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli run quote --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "--commit-output exowner1/context must also be given as --repo exowner1/context[@BRANCH]; the service commits only to a context repository", + "suggestedArgv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + }, + "files": [ + { + "path": "p.prose.md", + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n" + } + ] +} diff --git a/cli/conformance/cases/service/runs/quote-file-posts-run-body.json b/cli/conformance/cases/service/runs/quote-file-posts-run-body.json new file mode 100644 index 00000000..48f491ad --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-file-posts-run-body.json @@ -0,0 +1,171 @@ +{ + "id": "quote-file-posts-run-body", + "feature": "runs", + "operation": "run.quote", + "description": "With FILE, the quote is a POST of the body `run submit` would send (content, repositories, output) with its environment, repositories and output_repository query.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "p.prose.md", + "--environment", + "linux", + "--repo", + "exowner1/context", + "--commit-output", + "exowner1/context" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "POST", + "path": "/run/quote", + "query": { + "environment": "linux", + "repositories": "[{\"url\":\"https://github.com/exowner1/context\"}]", + "output_repository": "https://github.com/exowner1/context" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedBody": { + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n", + "output": { + "repository": "https://github.com/exowner1/context", + "type": "commit" + }, + "repositories": [ + { + "url": "https://github.com/exowner1/context" + } + ] + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-luna", + "source": "program" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "linux", + "source": "request" + }, + "tools": { + "value": [ + "web:search" + ], + "source": "program" + }, + "repositories": { + "value": 1, + "source": "request" + } + } + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": null, + "result": { + "basis": { + "environment": { + "source": "request", + "value": "linux" + }, + "model": { + "source": "program", + "value": "model-luna" + }, + "reasoning_effort": { + "source": "program", + "value": "medium" + }, + "repositories": { + "source": "request", + "value": 1 + }, + "tools": { + "source": "program", + "value": [ + "web:search" + ] + } + }, + "environment": "linux", + "hold": { + "hold_cents": 102, + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", + "note": "Unused hold is released when the run settles." + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + }, + "files": [ + { + "path": "p.prose.md", + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n" + } + ] +} diff --git a/cli/conformance/cases/service/runs/quote-file-unknown-environment-rejected.json b/cli/conformance/cases/service/runs/quote-file-unknown-environment-rejected.json new file mode 100644 index 00000000..3b968983 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-file-unknown-environment-rejected.json @@ -0,0 +1,96 @@ +{ + "id": "quote-file-unknown-environment-rejected", + "feature": "runs", + "operation": "run.quote", + "description": "With FILE, an unknown --environment is refused against the service's offered values before the quote, as `run submit` does.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "p.prose.md", + "--environment", + "nope" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + } + ] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": { + "action": "Rerun with an environment the service offers: `prose --output json cli run quote p.prose.md --environment builtin`.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "environment \"nope\" is not offered by this service (available: builtin, linux); retry with --environment builtin", + "suggestedArgv": [ + "--output", + "json", + "cli", + "run", + "quote", + "p.prose.md", + "--environment", + "builtin" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "files": [ + { + "path": "p.prose.md", + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n" + } + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-file-unknown-runtime-rejected.json b/cli/conformance/cases/service/runs/quote-file-unknown-runtime-rejected.json new file mode 100644 index 00000000..1c8b1d0e --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-file-unknown-runtime-rejected.json @@ -0,0 +1,101 @@ +{ + "id": "quote-file-unknown-runtime-rejected", + "feature": "runs", + "operation": "run.quote", + "description": "With FILE, an unknown --runtime is refused against the service's offered values before the quote, as `run submit` does.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "p.prose.md", + "--runtime", + "warp" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + }, + "runtimes": { + "available": [ + "reactor" + ] + } + } + } + ] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": { + "action": "Rerun with a runtime the service offers: `prose --output json cli run quote p.prose.md --runtime reactor`.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "runtime \"warp\" is not offered by this service (available: reactor); retry with --runtime reactor", + "suggestedArgv": [ + "--output", + "json", + "cli", + "run", + "quote", + "p.prose.md", + "--runtime", + "reactor" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "files": [ + { + "path": "p.prose.md", + "content": "---\nname: hello\nkind: function\n---\n\nReply with the word ok.\n" + } + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-from-own-commit-id-corrected.json b/cli/conformance/cases/service/runs/quote-from-own-commit-id-corrected.json new file mode 100644 index 00000000..87464e32 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-from-own-commit-id-corrected.json @@ -0,0 +1,118 @@ +{ + "id": "quote-from-own-commit-id-corrected", + "feature": "runs", + "operation": "run.quote", + "description": "`run quote --from SLUG@COMMIT_ID` names the rev_id to pass, as `run submit` does; nothing is quoted.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "hello@2222222222222222" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "clock": { + "start": "2026-09-24T01:00:00.000Z", + "stepMs": 0 + }, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/programs/hello/revisions", + "status": 200, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + }, + "body": { + "revisions": [ + { + "owner": "octo", + "slug": "hello", + "rev": 2, + "rev_id": "0123456789abcdef", + "commit_id": "1111111111111111", + "parent_commit_id": "2222222222222222", + "updated_at": 1790212199320 + }, + { + "owner": "octo", + "slug": "hello", + "rev": 1, + "rev_id": "fedcba9876543210", + "commit_id": "2222222222222222", + "parent_commit_id": null, + "updated_at": 1790212000000 + } + ] + } + } + ] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": { + "action": "Pass the rev_id: `prose --output json cli run quote --from hello@fedcba9876543210`.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "2222222222222222 is the commit_id of octo/hello rev 1, not a rev_id; a program reference pins the rev_id, fedcba9876543210", + "suggestedArgv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "hello@fedcba9876543210" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-from-own-latest.json b/cli/conformance/cases/service/runs/quote-from-own-latest.json new file mode 100644 index 00000000..79e7e1bb --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-from-own-latest.json @@ -0,0 +1,176 @@ +{ + "id": "quote-from-own-latest", + "feature": "runs", + "operation": "run.quote", + "description": "`run quote --from SLUG` prices the caller's own program at its newest listed revision.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "hello" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/programs/hello/revisions", + "status": 200, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + }, + "body": { + "revisions": [ + { + "owner": "octo", + "slug": "hello", + "rev": 2, + "rev_id": "0123456789abcdef", + "commit_id": "1111111111111111", + "parent_commit_id": "2222222222222222", + "updated_at": 1790212199320 + }, + { + "owner": "octo", + "slug": "hello", + "rev": 1, + "rev_id": "fedcba9876543210", + "commit_id": "2222222222222222", + "parent_commit_id": null, + "updated_at": 1790212000000 + } + ] + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "octo/hello@0123456789abcdef" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-luna", + "source": "program" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "builtin", + "source": "default" + }, + "tools": { + "value": [ + "web:search" + ], + "source": "program" + }, + "repositories": { + "value": 0, + "source": "default" + } + } + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": null, + "result": { + "basis": { + "environment": { + "source": "default", + "value": "builtin" + }, + "model": { + "source": "program", + "value": "model-luna" + }, + "reasoning_effort": { + "source": "program", + "value": "medium" + }, + "repositories": { + "source": "default", + "value": 0 + }, + "tools": { + "source": "program", + "value": [ + "web:search" + ] + } + }, + "environment": "builtin", + "hold": { + "hold_cents": 102, + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", + "note": "Unused hold is released when the run settles." + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-from-own-revision-number.json b/cli/conformance/cases/service/runs/quote-from-own-revision-number.json new file mode 100644 index 00000000..b012cc83 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-from-own-revision-number.json @@ -0,0 +1,176 @@ +{ + "id": "quote-from-own-revision-number", + "feature": "runs", + "operation": "run.quote", + "description": "`run quote --from SLUG@N` resolves the caller's own revision first, as `run submit` does, then prices the pinned program.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "hello@1" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/programs/hello/revisions", + "status": 200, + "requestHeaders": { + "Authorization": "Bearer rr_test_0123456789abcdef0123456789abcdef" + }, + "body": { + "revisions": [ + { + "owner": "octo", + "slug": "hello", + "rev": 2, + "rev_id": "0123456789abcdef", + "commit_id": "1111111111111111", + "parent_commit_id": "2222222222222222", + "updated_at": 1790212199320 + }, + { + "owner": "octo", + "slug": "hello", + "rev": 1, + "rev_id": "fedcba9876543210", + "commit_id": "2222222222222222", + "parent_commit_id": null, + "updated_at": 1790212000000 + } + ] + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "octo/hello@fedcba9876543210" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-luna", + "source": "program" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "builtin", + "source": "default" + }, + "tools": { + "value": [ + "web:search" + ], + "source": "program" + }, + "repositories": { + "value": 0, + "source": "default" + } + } + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": null, + "result": { + "basis": { + "environment": { + "source": "default", + "value": "builtin" + }, + "model": { + "source": "program", + "value": "model-luna" + }, + "reasoning_effort": { + "source": "program", + "value": "medium" + }, + "repositories": { + "source": "default", + "value": 0 + }, + "tools": { + "source": "program", + "value": [ + "web:search" + ] + } + }, + "environment": "builtin", + "hold": { + "hold_cents": 102, + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", + "note": "Unused hold is released when the run settles." + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-from-program-anonymous.json b/cli/conformance/cases/service/runs/quote-from-program-anonymous.json new file mode 100644 index 00000000..9abafa4a --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-from-program-anonymous.json @@ -0,0 +1,146 @@ +{ + "id": "quote-from-program-anonymous", + "feature": "runs", + "operation": "run.quote", + "description": "Without a key, `run quote --from` quotes a public program anonymously.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "exowner1/hello@0123456789abcdef" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": null + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/hello@0123456789abcdef" + }, + "requestHeaders": { + "Authorization": { + "present": false + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-luna", + "source": "program" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "builtin", + "source": "default" + }, + "tools": { + "value": [ + "web:search" + ], + "source": "program" + }, + "repositories": { + "value": 0, + "source": "default" + } + } + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": null, + "result": { + "basis": { + "environment": { + "source": "default", + "value": "builtin" + }, + "model": { + "source": "program", + "value": "model-luna" + }, + "reasoning_effort": { + "source": "program", + "value": "medium" + }, + "repositories": { + "source": "default", + "value": 0 + }, + "tools": { + "source": "program", + "value": [ + "web:search" + ] + } + }, + "environment": "builtin", + "hold": { + "hold_cents": 102, + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", + "note": "Unused hold is released when the run settles." + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-from-program-basis.json b/cli/conformance/cases/service/runs/quote-from-program-basis.json new file mode 100644 index 00000000..876d9edd --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-from-program-basis.json @@ -0,0 +1,149 @@ +{ + "id": "quote-from-program-basis", + "feature": "runs", + "operation": "run.quote", + "description": "`run quote --from OWNER/SLUG` asks the service to price that saved program with the key when one is set; --model overrides the program's model, and `basis` names where each value came from.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "exowner1/hello", + "--model", + "model-luna" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/hello", + "model": "model-luna" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-luna", + "source": "request" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "builtin", + "source": "default" + }, + "tools": { + "value": [ + "web:search" + ], + "source": "program" + }, + "repositories": { + "value": 0, + "source": "default" + } + } + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": null, + "result": { + "basis": { + "environment": { + "source": "default", + "value": "builtin" + }, + "model": { + "source": "request", + "value": "model-luna" + }, + "reasoning_effort": { + "source": "program", + "value": "medium" + }, + "repositories": { + "source": "default", + "value": 0 + }, + "tools": { + "source": "program", + "value": [ + "web:search" + ] + } + }, + "environment": "builtin", + "hold": { + "hold_cents": 102, + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", + "note": "Unused hold is released when the run settles." + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-from-program-not-found.json b/cli/conformance/cases/service/runs/quote-from-program-not-found.json new file mode 100644 index 00000000..16a0040b --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-from-program-not-found.json @@ -0,0 +1,97 @@ +{ + "id": "quote-from-program-not-found", + "feature": "runs", + "operation": "run.quote", + "description": "A program the caller cannot read is 404 program_not_found: SERVICE_RESOURCE_NOT_FOUND, exit 10.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "exowner1/missing" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/missing" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 404, + "body": { + "error": "Program not found.", + "code": "program_not_found" + } + } + ] + }, + "exitCode": 10, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": { + "action": "Check the identifier, then retry.", + "boundary": "hosted-service", + "code": "SERVICE_RESOURCE_NOT_FOUND", + "details": { + "serviceCode": "program_not_found", + "serviceStatus": 404 + }, + "exitCode": 10, + "message": "The requested OpenProse service resource was not found.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/quote-human.json b/cli/conformance/cases/service/runs/quote-human.json index 3a961010..ddb7ab9f 100644 --- a/cli/conformance/cases/service/runs/quote-human.json +++ b/cli/conformance/cases/service/runs/quote-human.json @@ -64,7 +64,7 @@ }, "exitCode": 0, "stdout": { - "text": "Environment: builtin\nHold: $1.02, set aside from the wallet while a run is live; not its price. Released within 900 s when unused\nDepends on: model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools\nPrice: known only after a run settles; read it with `prose cli run show RUN_ID`\nNote: Unused hold is released when the run settles.\n" + "text": "Environment: builtin\nHold: $1.02, set aside from the wallet while a run is live; not its price. Released within 900 s when unused\nDepends on: model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given\nPrice: known only after a run settles; read it with `prose cli run show RUN_ID`\nNote: Unused hold is released when the run settles.\n" }, "stderr": { "text": "" diff --git a/cli/conformance/cases/service/runs/quote-linux-environment.json b/cli/conformance/cases/service/runs/quote-linux-environment.json index 28d81141..df0478d6 100644 --- a/cli/conformance/cases/service/runs/quote-linux-environment.json +++ b/cli/conformance/cases/service/runs/quote-linux-environment.json @@ -82,7 +82,7 @@ "hold_usd": "1.02", "ttl_seconds": 900 }, - "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools; a run's price is known only after it settles", + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", "note": "Unused hold is released when the run settles." }, "schema": "openprose.service-operation/1" diff --git a/cli/conformance/cases/service/runs/quote-sends-hold-options.json b/cli/conformance/cases/service/runs/quote-sends-hold-options.json index 0001a612..47113cf5 100644 --- a/cli/conformance/cases/service/runs/quote-sends-hold-options.json +++ b/cli/conformance/cases/service/runs/quote-sends-hold-options.json @@ -2,16 +2,13 @@ "id": "quote-sends-hold-options", "feature": "runs", "operation": "run.quote", - "description": "`run quote` sends the hold options it was given: --model, --reasoning-effort, --environment, and repositories=1 for any --repo or --commit-output. The program and inputs are never sent.", + "description": "Without FILE or --from, `run quote` sends the hold options it was given as query parameters: --model, --reasoning-effort, --environment, and repositories=1 for any --repo or --commit-output.", "argv": [ "--output", "json", "cli", "run", "quote", - "p.prose.md", - "--input", - "topic=cats", "--model", "model-luna", "--reasoning-effort", @@ -96,7 +93,7 @@ "hold_usd": "1.02", "ttl_seconds": 900 }, - "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools; a run's price is known only after it settles", + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", "note": "Unused hold is released when the run settles." }, "schema": "openprose.service-operation/1" diff --git a/cli/conformance/cases/service/runs/quote-url-source-rejected.json b/cli/conformance/cases/service/runs/quote-url-source-rejected.json new file mode 100644 index 00000000..27bf9f09 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-url-source-rejected.json @@ -0,0 +1,54 @@ +{ + "id": "quote-url-source-rejected", + "feature": "runs", + "operation": "run.quote", + "description": "`run quote` refuses a URL as FILE, as `run submit` does.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "https://example.com/p.prose.md" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [] + }, + "exitCode": 2, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": { + "action": "Correct the value named in Detail; `prose --output json cli run quote --help` shows the accepted syntax.", + "boundary": "invocation", + "code": "INVOCATION_INVALID", + "details": { + "reason": "URL sources are not supported (\"https://example.com/p.prose.md\"); save the program to a file or run a saved program with --from OWNER/SLUG[@REV]", + "suggestedArgv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--help" + ] + }, + "exitCode": 2, + "message": "That command isn't quite right.", + "retryable": false, + "schema": "openprose.runner-error/1" + }, + "result": null, + "schema": "openprose.service-operation/1" + } + }, + "stderr": { + "text": "" + } +} diff --git a/cli/conformance/cases/service/runs/submit-confirmation-quote-failure-keeps-plan.json b/cli/conformance/cases/service/runs/submit-confirmation-quote-failure-keeps-plan.json index a2b78a85..8959aebd 100644 --- a/cli/conformance/cases/service/runs/submit-confirmation-quote-failure-keeps-plan.json +++ b/cli/conformance/cases/service/runs/submit-confirmation-quote-failure-keeps-plan.json @@ -25,13 +25,14 @@ "storeAvailable": true, "exchanges": [ { - "method": "GET", + "method": "POST", "path": "/run/quote", "requestHeaders": { "Authorization": { - "present": false + "present": true } }, + "expectedSha256": "ff2b93e1185f8f379d05b79546262b4bede0761fc4868b35ad38d360497d7060", "status": 500, "body": { "error": "boom" diff --git a/cli/conformance/cases/service/runs/submit-confirmation-required-quote.json b/cli/conformance/cases/service/runs/submit-confirmation-required-quote.json index d2e50952..8afd0557 100644 --- a/cli/conformance/cases/service/runs/submit-confirmation-required-quote.json +++ b/cli/conformance/cases/service/runs/submit-confirmation-required-quote.json @@ -25,13 +25,14 @@ "storeAvailable": true, "exchanges": [ { - "method": "GET", + "method": "POST", "path": "/run/quote", "requestHeaders": { "Authorization": { - "present": false + "present": true } }, + "expectedSha256": "ff2b93e1185f8f379d05b79546262b4bede0761fc4868b35ad38d360497d7060", "status": 200, "body": { "hold": { diff --git a/cli/conformance/cases/service/runs/submit-from-rev-number-pins.json b/cli/conformance/cases/service/runs/submit-from-rev-number-pins.json index 04795513..d968ddef 100644 --- a/cli/conformance/cases/service/runs/submit-from-rev-number-pins.json +++ b/cli/conformance/cases/service/runs/submit-from-rev-number-pins.json @@ -55,8 +55,14 @@ } }, { - "method": "GET", + "method": "POST", "path": "/run/quote", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedSha256": "c223a3f080d5b558d64d0344c03bf5d95a30681c542a7daaa4bd40bbe6d190fa", "status": 200, "body": { "hold": { diff --git a/cli/conformance/cases/service/runs/submit-from-rev-word-pins.json b/cli/conformance/cases/service/runs/submit-from-rev-word-pins.json index d1016ee4..9078fb6c 100644 --- a/cli/conformance/cases/service/runs/submit-from-rev-word-pins.json +++ b/cli/conformance/cases/service/runs/submit-from-rev-word-pins.json @@ -55,8 +55,14 @@ } }, { - "method": "GET", + "method": "POST", "path": "/run/quote", + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "expectedSha256": "4e7292099c313ca126b548af5fdc83a5594f4243b90b5b428f5447c9579d8f71", "status": 200, "body": { "hold": { diff --git a/cli/conformance/cases/service/runs/submit-preview-hidden-model-accepted.json b/cli/conformance/cases/service/runs/submit-preview-hidden-model-accepted.json index b4a2254c..19782ef0 100644 --- a/cli/conformance/cases/service/runs/submit-preview-hidden-model-accepted.json +++ b/cli/conformance/cases/service/runs/submit-preview-hidden-model-accepted.json @@ -64,16 +64,14 @@ } }, { - "method": "GET", + "method": "POST", "path": "/run/quote", - "query": { - "model": "model-sol-legacy" - }, "requestHeaders": { "Authorization": { - "present": false + "present": true } }, + "expectedSha256": "adf31169f6fff1cb2d26d00e1b57450feabc75818c5186a743c199632d3ee5f8", "status": 200, "body": { "hold": { diff --git a/cli/conformance/cases/service/runs/submit-preview-repo-quote.json b/cli/conformance/cases/service/runs/submit-preview-repo-quote.json index 03ae7726..8943858d 100644 --- a/cli/conformance/cases/service/runs/submit-preview-repo-quote.json +++ b/cli/conformance/cases/service/runs/submit-preview-repo-quote.json @@ -50,17 +50,18 @@ } }, { - "method": "GET", + "method": "POST", "path": "/run/quote", "query": { "environment": "linux", - "repositories": "1" + "repositories": "[{\"url\":\"https://github.com/exowner1/context\"}]" }, "requestHeaders": { "Authorization": { - "present": false + "present": true } }, + "expectedSha256": "2e953120dee6861bc0f1f02c946096995fdc1ff0490834809a43a826938c9769", "status": 200, "body": { "hold": { diff --git a/cli/conformance/cases/service/runs/submit-preview-summary-human.json b/cli/conformance/cases/service/runs/submit-preview-summary-human.json index 5ff01cfa..0360abda 100644 --- a/cli/conformance/cases/service/runs/submit-preview-summary-human.json +++ b/cli/conformance/cases/service/runs/submit-preview-summary-human.json @@ -50,16 +50,14 @@ } }, { - "method": "GET", + "method": "POST", "path": "/run/quote", - "query": { - "model": "model-luna" - }, "requestHeaders": { "Authorization": { - "present": false + "present": true } }, + "expectedSha256": "98955150e7e049b9cd674891bb034679fdbb791ba8aec1bd3cb17bbd5df780f8", "status": 200, "body": { "hold": { diff --git a/cli/conformance/cases/service/runs/submit-preview-summary.json b/cli/conformance/cases/service/runs/submit-preview-summary.json index cb2cc18d..414d203e 100644 --- a/cli/conformance/cases/service/runs/submit-preview-summary.json +++ b/cli/conformance/cases/service/runs/submit-preview-summary.json @@ -54,17 +54,14 @@ } }, { - "method": "GET", + "method": "POST", "path": "/run/quote", - "query": { - "model": "model-luna", - "reasoning_effort": "high" - }, "requestHeaders": { "Authorization": { - "present": false + "present": true } }, + "expectedSha256": "47174bcbacbfe05188410ba75e45b52acb1bc7314f1bd3408e2e2eec1bbc0060", "status": 200, "body": { "hold": { diff --git a/cli/conformance/cases/service/runs/submit-preview-with-quote.json b/cli/conformance/cases/service/runs/submit-preview-with-quote.json index ef0709e1..b6a9de6a 100644 --- a/cli/conformance/cases/service/runs/submit-preview-with-quote.json +++ b/cli/conformance/cases/service/runs/submit-preview-with-quote.json @@ -48,13 +48,17 @@ } }, { - "method": "GET", + "method": "POST", "path": "/run/quote", + "query": { + "environment": "linux" + }, "requestHeaders": { "Authorization": { - "present": false + "present": true } }, + "expectedSha256": "ff2b93e1185f8f379d05b79546262b4bede0761fc4868b35ad38d360497d7060", "status": 200, "body": { "hold": { @@ -63,9 +67,6 @@ }, "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", "note": "Unused hold is released when the run settles." - }, - "query": { - "environment": "linux" } } ] diff --git a/cli/rust/crates/prose-cli/tests/service_runs.rs b/cli/rust/crates/prose-cli/tests/service_runs.rs index b1ac8109..fda28951 100644 --- a/cli/rust/crates/prose-cli/tests/service_runs.rs +++ b/cli/rust/crates/prose-cli/tests/service_runs.rs @@ -91,7 +91,7 @@ fn an_input_file_of_exactly_one_mebibyte_is_planned() { std::fs::write(temp.path().join("p.prose.md"), "Reply ok.\n").unwrap(); std::fs::write(temp.path().join("max.txt"), "y".repeat(MEBIBYTE)).unwrap(); let fixture = json!({"environment": "production", "exchanges": [ - {"method": "GET", "path": "/run/quote", "status": 200, + {"method": "POST", "path": "/run/quote", "status": 200, "body": {"hold": {"hold_usd": "1.02", "ttl_seconds": 900}, "pricing_policy_id": "p"}} ]}); let output = prose( diff --git a/cli/rust/crates/prose-runner-core/src/service/jobs.rs b/cli/rust/crates/prose-runner-core/src/service/jobs.rs index 9af21cd1..f14ae05a 100644 --- a/cli/rust/crates/prose-runner-core/src/service/jobs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/jobs.rs @@ -1106,7 +1106,8 @@ fn create(context: &mut Context<'_>) -> Result { { let mut reference = program_ref::parse_own_allowed_with(&given, true)?; if reference.pinned().is_none() || reference.owner.is_empty() { - let pinned = program_ref::resolve_to_run(context, &mut reference, 2, 3, None, false)?; + let pinned = + program_ref::resolve_to_run(context, &mut reference, 2, Some(3), None, false)?; if pinned != given { spec.insert("program_ref".into(), json!(pinned)); body = canonical(&Value::Object(spec.clone())).into_bytes(); @@ -1134,11 +1135,12 @@ fn create(context: &mut Context<'_>) -> Result { Ok(result) } -/// The anonymous `GET /run/quote` hold for the confirmation plan. It sends -/// the hold options the spec gives (`model`, `reasoning_effort`, -/// `environment`, and `repositories=1` for a `repository_url` or -/// `context_repository_url`), never defaults; the program is not read, so its -/// declared tools are not sent. The price policy reference stays internal. +/// The `GET /run/quote` hold for the confirmation plan, sent with the key. +/// It names the pinned `program_ref` the job stores, so the service prices +/// that program with its own run settings and declared tools, and sends the +/// hold options the spec gives (`model`, `reasoning_effort`, `environment`, +/// and `repositories=1` for a `repository_url` or `context_repository_url`) +/// as overrides, never defaults. The price policy reference stays internal. fn quote(context: &mut Context<'_>, spec: &Map) -> Result { let text = |key: &str| { spec.get(key) @@ -1147,8 +1149,12 @@ fn quote(context: &mut Context<'_>, spec: &Map) -> Result { Ok(token) } + /// Whether a credential is configured (the environment variable, or a + /// stored key). A request whose manifest `auth` is `optional` sends the + /// key exactly when one is configured, and is anonymous otherwise. + pub fn has_credential(&mut self) -> bool { + let variable = self.environment.credential_env; + if self + .system + .environment + .get(variable) + .is_some_and(|value| !value.is_empty()) + { + return true; + } + let environment = self.environment.clone(); + matches!(self.transport.stored_credential(&environment), Ok(Some(_))) + } + /// Names the credential source of a service authentication failure ///: an environment key is not replaced by `cli auth login`. fn annotate_auth(&self, error: &mut RunnerError) { diff --git a/cli/rust/crates/prose-runner-core/src/service/program_ref.rs b/cli/rust/crates/prose-runner-core/src/service/program_ref.rs index 15de1e39..827bef1c 100644 --- a/cli/rust/crates/prose-runner-core/src/service/program_ref.rs +++ b/cli/rust/crates/prose-runner-core/src/service/program_ref.rs @@ -345,13 +345,15 @@ pub fn resolve_rev_number( /// pass), and a latest reference reads the newest `rev_id`. Another owner's /// pinned reference is checked by the service. `list` and `read` are the /// operation's `GET /programs/{slug}/revisions` and `GET /p/{owner}/{slug}` -/// request indexes; `option` is the argv option the reference came from, so -/// a fix is a corrected argv. +/// request indexes; without a `read` request a latest reference of the +/// caller's own program pins its newest listed revision, and another owner's +/// latest reference stays `owner/slug`. `option` is the argv option the +/// reference came from, so a fix is a corrected argv. pub fn resolve_to_run( context: &mut Context<'_>, reference: &mut ProgramRef, list: usize, - read: usize, + read: Option, option: Option<&str>, verify_pinned: bool, ) -> Result { @@ -415,6 +417,14 @@ pub fn resolve_to_run( } return Err(no_revision(context, &name, &slug, &rev, &revisions)); } + } else if reference.rev.is_none() && read.is_none() { + // The listing is newest first. + let newest = revisions + .first() + .and_then(|item| item["rev_id"].as_str()) + .filter(|rev| valid_rev(rev)) + .ok_or_else(|| RunnerError::catalog(ErrorCode::ServiceProtocolInvalid))?; + reference.rev = Some(newest.to_owned()); } } _ => { @@ -440,8 +450,12 @@ pub fn resolve_to_run( } } } - let (pinned, _) = resolve(context, reference, read)?; - Ok(pinned) + match read { + Some(read) => Ok(resolve(context, reference, read)?.0), + None => Ok(reference + .pinned() + .unwrap_or_else(|| format!("{}/{}", reference.owner, reference.slug))), + } } /// `SERVICE_RESOURCE_NOT_FOUND` for a revision the caller's program does not diff --git a/cli/rust/crates/prose-runner-core/src/service/programs.rs b/cli/rust/crates/prose-runner-core/src/service/programs.rs index 3ff89dab..2fbf2732 100644 --- a/cli/rust/crates/prose-runner-core/src/service/programs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/programs.rs @@ -272,25 +272,6 @@ fn list(context: &mut Context<'_>) -> Result { Ok(json!({ "programs": programs })) } -/// `program show` reads anonymously when no credential is configured (public -/// programs); a configured key is always sent, so owners see private ones. -fn has_credential(context: &mut Context<'_>) -> bool { - let variable = context.environment.credential_env; - if context - .system - .environment - .get(variable) - .is_some_and(|value| !value.is_empty()) - { - return true; - } - let environment = context.environment.clone(); - matches!( - context.transport.stored_credential(&environment), - Ok(Some(_)) - ) -} - /// A bare SLUG that is not one of the caller's programs: when the caller's /// program list (manifest request 2) has one slug within two edits, the /// suggestion is the same command with that slug. The listing is advisory: a @@ -375,7 +356,10 @@ fn show(context: &mut Context<'_>) -> Result { path.push_str(rev); } let mut request = Request::from_manifest(context.operation, 0, path); - request.bearer = has_credential(context); + // `program show` reads anonymously when no credential is configured + // (public programs); a configured key is always sent, so owners see + // private ones. + request.bearer = context.has_credential(); let body = context.send(&request)?.json_object()?; let record = body.get("program").cloned().unwrap_or(Value::Null); let program = project(&record, true, false)?; diff --git a/cli/rust/crates/prose-runner-core/src/service/runs.rs b/cli/rust/crates/prose-runner-core/src/service/runs.rs index ff2a1903..78a4f7bb 100644 --- a/cli/rust/crates/prose-runner-core/src/service/runs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/runs.rs @@ -402,16 +402,19 @@ fn quote_fields(body: &Map) -> Result { /// `run quote` `holdBasis`: what the hold depends on and what the quote /// covers; the hold is not a price estimate. -const HOLD_BASIS: &str = "depends on model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools; a run's price is known only after it settles"; +const HOLD_BASIS: &str = "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles"; /// The `run quote` human line naming what the hold depends on. -const HOLD_DEPENDS_ON: &str = "Depends on: model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools"; +const HOLD_DEPENDS_ON: &str = "Depends on: model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given"; + +/// The most declared tools a quote basis names. +const MAX_BASIS_TOOLS: usize = 16; /// Adds the hold options the caller gave to a `GET /run/quote` request, in /// manifest order. Only given options are sent, never defaults, so a quote /// without them stays parameter-free. Any bound repository sends -/// `repositories=1`. The program is never read, so declared tools are not -/// sent. +/// `repositories=1`. With a `program_ref` they override the program's own +/// values. pub(super) fn hold_query( mut request: Request, model: Option<&str>, @@ -432,7 +435,101 @@ pub(super) fn hold_query( request } +/// The `basis` of a quote: where each hold input came from. `None` when the +/// service sends none (older services); anything else must be the five +/// `{value, source}` entries, or the quote is `SERVICE_PROTOCOL_INVALID`. +fn quote_basis(body: &Map) -> Result, RunnerError> { + let basis = match body.get("basis") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Object(basis)) => basis, + Some(_) => return Err(protocol("quote basis is malformed")), + }; + let mut projected = Map::new(); + for key in [ + "model", + "reasoning_effort", + "environment", + "tools", + "repositories", + ] { + let malformed = || protocol(format!("quote basis.{key} is missing or malformed")); + let entry = basis + .get(key) + .and_then(Value::as_object) + .ok_or_else(malformed)?; + let source = entry + .get("source") + .and_then(Value::as_str) + .filter(|source| matches!(*source, "request" | "program" | "default")) + .ok_or_else(malformed)?; + let text = |value: &Value| { + value + .as_str() + .is_some_and(|text| render::valid_text(text, 64)) + }; + let value = entry.get("value").ok_or_else(malformed)?; + let valid = match key { + "tools" => value + .as_array() + .is_some_and(|tools| tools.len() <= MAX_BASIS_TOOLS && tools.iter().all(text)), + "repositories" => value.is_u64(), + _ => text(value), + }; + if !valid { + return Err(malformed()); + } + projected.insert(key.to_owned(), json!({"value": value, "source": source})); + } + Ok(Some(Value::Object(projected))) +} + +/// The human `Basis:` line: each value with where it came from. +fn basis_line(basis: &Value) -> String { + let part = |key: &str, label: &str| { + let entry = &basis[key]; + let value = match &entry["value"] { + Value::Array(tools) if tools.is_empty() => "none".to_owned(), + Value::Array(tools) => tools + .iter() + .filter_map(Value::as_str) + .map(human_safe_scalar) + .collect::>() + .join(", "), + Value::String(text) => human_safe_scalar(text), + other => other.to_string(), + }; + format!( + "{label} {value} ({})", + entry["source"].as_str().unwrap_or_default() + ) + }; + format!( + "Basis: {}; {}; {}; {}; {}", + part("model", "model"), + part("reasoning_effort", "reasoning effort"), + part("environment", "environment"), + part("tools", "tools"), + part("repositories", "repositories") + ) +} + +/// The `POST /run/quote` request (3) for `run quote FILE`: the program and +/// run options exactly as `run submit` sends them, never inputs. +fn program_quote(context: &Context<'_>, file: &str) -> Result { + let options = run_options(context)?; + let program_text = read_program(context, file)?; + let mut body = Map::new(); + body.insert("content".into(), json!(program_text)); + let mut request = Request::from_manifest(context.operation, 3, "/run/quote"); + request.query = add_run_options(&mut body, &options); + request.body = Some(render::canonical(&Value::Object(body)).into_bytes()); + Ok(request) +} + fn quote(context: &mut Context<'_>) -> Result { + let file = context.argument("FILE").map(str::to_owned); + let from = context.option("--from").map(str::to_owned); + check_program_source(file.as_deref(), from.as_deref())?; let requested = context.option("--environment").map(str::to_owned); if let Some(environment) = &requested { if !valid_token(environment) { @@ -442,9 +539,27 @@ fn quote(context: &mut Context<'_>) -> Result { ))); } } + let program = file + .as_deref() + .map(|file| program_quote(context, file)) + .transpose()?; + let reference = from + .as_deref() + .map(|value| program_ref::parse_own_allowed_with(value, true)) + .transpose()?; let health = context .send(&Request::from_manifest(context.operation, 0, "/health"))? .json_object()?; + if program.is_some() { + // FILE: an environment or runtime the service does not offer is + // refused exactly as `run submit` refuses it. + check_offered_in( + context, + &Value::Object(health.clone()), + requested.as_deref(), + context.option("--runtime"), + )?; + } let environments = health.get("environments").cloned().unwrap_or(Value::Null); let available = environments["available"] .as_array() @@ -475,13 +590,40 @@ fn quote(context: &mut Context<'_>) -> Result { }; let repositories_bound = !context.invocation.option_values("--repo").is_empty() || context.option("--commit-output").is_some(); - let request = hold_query( - Request::from_manifest(context.operation, 1, "/run/quote"), - context.option("--model"), - context.option("--reasoning-effort"), - requested.as_deref(), - repositories_bound, - ); + let request = if let Some(mut request) = program { + // FILE: the service prices the program text (request 3). + request.bearer = context.has_credential(); + request + } else if let Some(mut reference) = reference { + // --from: the service prices the saved program (request 2). A + // reference naming an owner is sent as given; a bare SLUG or `@N` + // is the caller's own program, pinned (and a given revision + // checked) through the caller's revisions first (request 4), as + // `run submit --from` does. + let program_ref = if reference.owner.is_empty() || reference.rev_number.is_some() { + program_ref::resolve_to_run(context, &mut reference, 4, None, Some("--from"), true)? + } else { + from.clone().unwrap_or_default() + }; + let mut request = hold_query( + Request::from_manifest(context.operation, 2, "/run/quote") + .query("program_ref", program_ref), + context.option("--model"), + context.option("--reasoning-effort"), + requested.as_deref(), + repositories_bound, + ); + request.bearer = context.has_credential(); + request + } else { + hold_query( + Request::from_manifest(context.operation, 1, "/run/quote"), + context.option("--model"), + context.option("--reasoning-effort"), + requested.as_deref(), + repositories_bound, + ) + }; let body = context.send(&request)?.json_object()?; let hold = quote_fields(&body)?; let note = match body.get("note") { @@ -489,6 +631,13 @@ fn quote(context: &mut Context<'_>) -> Result { Some(Value::String(note)) => clean_line(note, 512), Some(_) => return Err(protocol("quote note is malformed")), }; + let basis = quote_basis(&body)?; + // The service names the environment it priced (the program's own, for + // example); without a basis it is the requested or default one. + let environment = basis + .as_ref() + .and_then(|basis| basis["environment"]["value"].as_str()) + .map_or(environment, str::to_owned); let mut text = format!("Environment: {}\n", human_safe_scalar(&environment)); let _ = writeln!( text, @@ -497,6 +646,9 @@ fn quote(context: &mut Context<'_>) -> Result { hold["ttl_seconds"] ); let _ = writeln!(text, "{HOLD_DEPENDS_ON}"); + if let Some(basis) = &basis { + let _ = writeln!(text, "{}", basis_line(basis)); + } let _ = writeln!( text, "Price: known only after a run settles; read it with `{}`", @@ -506,12 +658,16 @@ fn quote(context: &mut Context<'_>) -> Result { let _ = writeln!(text, "Note: {}", human_safe_scalar(¬e)); } context.human = Some(text); - Ok(json!({ + let mut result = json!({ "environment": environment, "hold": hold, "holdBasis": HOLD_BASIS, "note": note, - })) + }); + if let Some(basis) = basis { + result["basis"] = basis; + } + Ok(result) } // --------------------------------------------------------------------------- @@ -1547,42 +1703,37 @@ struct Submission { source_sha256: Option, session: Option, wait_ms: u64, - environment: Option, - /// The hold options sent with the plan's quote: --model, - /// --reasoning-effort and whether any repository is bound. - model: Option, - reasoning_effort: Option, - repositories_bound: bool, } -fn prepare_submission(context: &mut Context<'_>) -> Result { - let file = context.argument("FILE").map(str::to_owned); - let from = context.option("--from").map(str::to_owned); - match (&file, &from) { - (Some(_), Some(_)) => { - return Err(invalid( - "give either FILE or --from OWNER/SLUG[@REV], not both", - )); - } - (None, None) => { - return Err(invalid( - "missing program: give FILE, - for standard input, or --from OWNER/SLUG[@REV]", - )); - } - _ => {} +/// Refuses FILE together with `--from`, and a URL as FILE (`run submit` and +/// `run quote` alike). +fn check_program_source(file: Option<&str>, from: Option<&str>) -> Result<(), RunnerError> { + if file.is_some() && from.is_some() { + return Err(invalid( + "give either FILE or --from OWNER/SLUG[@REV], not both", + )); } - if let Some(file) = file.as_deref().filter(|file| looks_like_url(file)) { + if let Some(file) = file.filter(|file| looks_like_url(file)) { return Err(invalid(format!( "URL sources are not supported ({file_quoted}); save the program to a file or run a saved program with --from OWNER/SLUG[@REV]", file_quoted = crate::error::quote(file) ))); } - let reference = from - .as_deref() - .map(|value| program_ref::parse_own_allowed_with(value, true)) - .transpose()?; - let session = session_option(context)?; - let wait_ms = wait_option(context)?; + Ok(()) +} + +/// The validated run options a submission body carries, read the same way +/// by `run submit` and `run quote FILE`. +struct RunOptions { + model: Option, + reasoning_effort: Option, + environment: Option, + runtime: Option, + repositories: Vec, + commit: Option, +} + +fn run_options(context: &Context<'_>) -> Result { let model = context.option("--model").map(str::to_owned); if let Some(model) = model.as_deref().filter(|model| !valid_model(model)) { return Err(invalid(format!( @@ -1637,35 +1788,42 @@ fn prepare_submission(context: &mut Context<'_>) -> Result, file: &str) -> Result { + let cwd = context.system.current_dir.clone(); + let program_text = super::fs::read_text(&cwd, file, MAX_PROGRAM_BYTES, "program file")?; + if program_text.trim().is_empty() { + return Err(invalid(format!( + "program file {file_quoted} is empty", + file_quoted = crate::error::quote(file) + ))); } - if let Some(model) = model { + Ok(program_text) +} + +/// Adds the options' `model`, `reasoning_effort`, `repositories` and `output` +/// to a submission body and returns its query after `live` and `session`: +/// `environment`, `runtime`, `repositories` (the body's array as canonical +/// JSON) and `output_repository`, each only when given. +fn add_run_options(body: &mut Map, options: &RunOptions) -> Vec<(String, String)> { + if let Some(model) = &options.model { body.insert("model".into(), json!(model)); } - if let Some(effort) = effort { + if let Some(effort) = &options.reasoning_effort { body.insert("reasoning_effort".into(), json!(effort)); } - let repository_values = repositories + let repository_values = options + .repositories .iter() .map(|repository| { let mut value = Map::new(); @@ -1677,10 +1835,10 @@ fn prepare_submission(context: &mut Context<'_>) -> Result>(); let mut extra_query = Vec::new(); - if let Some(environment) = &environment { + if let Some(environment) = &options.environment { extra_query.push(("environment".to_owned(), environment.clone())); } - if let Some(runtime) = &runtime { + if let Some(runtime) = &options.runtime { extra_query.push(("runtime".to_owned(), runtime.clone())); } if !repository_values.is_empty() { @@ -1690,7 +1848,7 @@ fn prepare_submission(context: &mut Context<'_>) -> Result) -> Result) -> Result { + let file = context.argument("FILE").map(str::to_owned); + let from = context.option("--from").map(str::to_owned); + if file.is_none() && from.is_none() { + return Err(invalid( + "missing program: give FILE, - for standard input, or --from OWNER/SLUG[@REV]", + )); + } + check_program_source(file.as_deref(), from.as_deref())?; + let reference = from + .as_deref() + .map(|value| program_ref::parse_own_allowed_with(value, true)) + .transpose()?; + let session = session_option(context)?; + let wait_ms = wait_option(context)?; + let options = run_options(context)?; + let inputs = parse_inputs(context)?; + let mut body = Map::new(); + let mut source_sha256 = None; + if let Some(file) = &file { + let program_text = read_program(context, file)?; + source_sha256 = Some(format!("{:x}", Sha256::digest(program_text.as_bytes()))); + check_parameters(&program_text, &inputs)?; + body.insert("content".into(), json!(program_text)); + } + if !inputs.is_empty() { + body.insert("inputs".into(), json!(inputs)); + } + let extra_query = add_run_options(&mut body, &options); + if let Some(model) = &options.model { // An unknown model fails before confirmation (request 4). - context.check_model(4, &model)?; + context.check_model(4, model)?; } // So do an environment or runtime the service does not offer (GET /health). - check_offered(context, environment.as_deref(), runtime.as_deref())?; + check_offered( + context, + options.environment.as_deref(), + options.runtime.as_deref(), + )?; if let Some(mut reference) = reference { // A bare SLUG, `@N` and the caller's own pinned revisions resolve // through the caller's revisions (request 3); a latest reference // reads the newest rev_id (request 0). let pinned = - program_ref::resolve_to_run(context, &mut reference, 3, 0, Some("--from"), true)?; + program_ref::resolve_to_run(context, &mut reference, 3, Some(0), Some("--from"), true)?; body.insert("program_ref".into(), json!(pinned)); } let body = render::canonical(&Value::Object(body)).into_bytes(); @@ -1727,10 +1921,6 @@ fn prepare_submission(context: &mut Context<'_>) -> Result return Err(error), Err(_) => return Ok(()), }; + check_offered_in(context, &health, environment, runtime) +} + +/// [`check_offered`] against a `/health` body already read: an +/// `--environment` or `--runtime` the body lists none of is refused with the +/// corrected command; an absent or empty list leaves the check to the +/// service. +fn check_offered_in( + context: &Context<'_>, + health: &Value, + environment: Option<&str>, + runtime: Option<&str>, +) -> Result<(), RunnerError> { for (option, key, value) in [ ("--environment", "environments", environment), ("--runtime", "runtimes", runtime), @@ -2027,14 +2230,12 @@ fn submit(context: &mut Context<'_>) -> Result { let placeholder = submission.session.as_deref().unwrap_or("{session}"); let query = submit_query(placeholder, &submission.extra_query); let mut planned = context.planned(2, "/run", &query, Some(&submission.body)); - let quote_request = hold_query( - Request::from_manifest(context.operation, 1, "/run/quote") - .class(TransportClass::Control), - submission.model.as_deref(), - submission.reasoning_effort.as_deref(), - submission.environment.as_deref(), - submission.repositories_bound, - ); + // The quote prices exactly this submission: the same body bytes and + // the `POST /run` query without `live` and `session`. + let mut quote_request = Request::from_manifest(context.operation, 1, "/run/quote") + .class(TransportClass::Control); + quote_request.query.clone_from(&submission.extra_query); + quote_request.body = Some(submission.body.clone()); // The quote is advisory: a failed quote never hides the plan. if let Ok(Ok(body)) = context .send("e_request) @@ -2887,4 +3088,61 @@ mod tests { ("unrecognized", json!({"name": "browser_live_view"})) ); } + + fn basis_body(tools: Value, repositories: Value) -> Map { + let entry = |value: Value, source: &str| json!({"value": value, "source": source}); + json!({"basis": { + "model": entry(json!("model-luna"), "request"), + "reasoning_effort": entry(json!("medium"), "program"), + "environment": entry(json!("builtin"), "default"), + "tools": entry(tools, "program"), + "repositories": entry(repositories, "default"), + "later": "ignored", + }}) + .as_object() + .unwrap() + .clone() + } + + #[test] + fn quote_basis_is_optional_closed_and_strict() { + assert_eq!(quote_basis(&Map::new()).unwrap(), None); + let basis = quote_basis(&basis_body(json!(["web:search", "shell"]), json!(0))) + .unwrap() + .unwrap(); + assert_eq!(basis.as_object().unwrap().len(), 5); + assert_eq!( + basis_line(&basis), + "Basis: model model-luna (request); reasoning effort medium (program); environment builtin (default); tools web:search, shell (program); repositories 0 (default)" + ); + let none = quote_basis(&basis_body(json!([]), json!(2))) + .unwrap() + .unwrap(); + assert!(basis_line(&none).contains("; tools none (program); repositories 2 (default)")); + let seventeen = (0..17).map(|n| json!(format!("t{n}"))).collect::>(); + for (tools, repositories) in [ + (json!("web:search"), json!(0)), + (Value::Array(seventeen), json!(0)), + (json!(["a\u{7}"]), json!(0)), + (json!([""]), json!(0)), + (json!(["x".repeat(65)]), json!(0)), + (json!([]), json!(-1)), + (json!([]), json!(1.5)), + (json!([]), json!("1")), + ] { + let error = quote_basis(&basis_body(tools, repositories)).unwrap_err(); + assert_eq!(error.code, ErrorCode::ServiceProtocolInvalid); + } + let mut wrong_source = basis_body(json!([]), json!(0)); + wrong_source["basis"]["model"]["source"] = json!("guess"); + assert!(quote_basis(&wrong_source).is_err()); + let mut missing = basis_body(json!([]), json!(0)); + missing["basis"] + .as_object_mut() + .unwrap() + .remove("environment"); + assert!(quote_basis(&missing).is_err()); + let not_object = json!({"basis": []}).as_object().unwrap().clone(); + assert!(quote_basis(¬_object).is_err()); + } } diff --git a/cli/shared/schemas/runner-error.schema.json b/cli/shared/schemas/runner-error.schema.json index 467a14fb..ab2a33a9 100644 --- a/cli/shared/schemas/runner-error.schema.json +++ b/cli/shared/schemas/runner-error.schema.json @@ -184,6 +184,7 @@ "organization_not_found", "paid_model_required", "paid_top_up_required", + "program_not_found", "provider_tier_unavailable", "rate_limited", "reserved_slug", diff --git a/cli/shared/schemas/service/common.schema.json b/cli/shared/schemas/service/common.schema.json index ea2a9753..6a5d5c8e 100644 --- a/cli/shared/schemas/service/common.schema.json +++ b/cli/shared/schemas/service/common.schema.json @@ -281,6 +281,137 @@ "minimum": 0 } } + }, + "quoteBasis": { + "description": "Where each hold input came from: the request, the program's own run settings or declared tools, or the service default.", + "type": "object", + "additionalProperties": false, + "required": [ + "model", + "reasoning_effort", + "environment", + "tools", + "repositories" + ], + "properties": { + "model": { + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "source" + ], + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[^\\u0000-\\u001f\\u007f]*$" + }, + "source": { + "enum": [ + "request", + "program", + "default" + ] + } + } + }, + "reasoning_effort": { + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "source" + ], + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[^\\u0000-\\u001f\\u007f]*$" + }, + "source": { + "enum": [ + "request", + "program", + "default" + ] + } + } + }, + "environment": { + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "source" + ], + "properties": { + "value": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[^\\u0000-\\u001f\\u007f]*$" + }, + "source": { + "enum": [ + "request", + "program", + "default" + ] + } + } + }, + "tools": { + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "source" + ], + "properties": { + "value": { + "type": "array", + "maxItems": 16, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[^\\u0000-\\u001f\\u007f]*$" + } + }, + "source": { + "enum": [ + "request", + "program", + "default" + ] + } + } + }, + "repositories": { + "type": "object", + "additionalProperties": false, + "required": [ + "value", + "source" + ], + "properties": { + "value": { + "type": "integer", + "minimum": 0 + }, + "source": { + "enum": [ + "request", + "program", + "default" + ] + } + } + } + } } } } diff --git a/cli/shared/schemas/service/runs.schema.json b/cli/shared/schemas/service/runs.schema.json index e0d74dda..f99f4814 100644 --- a/cli/shared/schemas/service/runs.schema.json +++ b/cli/shared/schemas/service/runs.schema.json @@ -14,6 +14,9 @@ "note" ], "properties": { + "basis": { + "$ref": "common.schema.json#/$defs/quoteBasis" + }, "environment": { "type": "string", "maxLength": 64, @@ -24,7 +27,7 @@ }, "holdBasis": { "description": "What the hold depends on and what this quote covers; the hold is a reservation, not a price estimate.", - "const": "depends on model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools; a run's price is known only after it settles" + "const": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles" }, "note": { "type": "string", diff --git a/cli/shared/service/guide.v1.md b/cli/shared/service/guide.v1.md index 58c61fa5..33609abc 100644 --- a/cli/shared/service/guide.v1.md +++ b/cli/shared/service/guide.v1.md @@ -106,11 +106,11 @@ use is released. Short runs usually cost a few cents. Estimate from your own history: `prose cli wallet usage` prints runs and prices by day, and `prose cli wallet balance` shows what is available and -what is reserved right now. `prose cli run quote --json` reports the hold for -the `--model`, `--reasoning-effort`, `--environment`, `--repo` and -`--commit-output` you give it. 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. +what is reserved right now. `prose cli run quote FILE --json` (or `--from +OWNER/SLUG`) reports the hold for that program, including its own run +settings and declared tools; `--model`, `--reasoning-effort`, `--environment`, +`--repo` and `--commit-output` override them. `result.basis` names where each +value came from (request, program or default). Premium models unlock with any wallet top-up; `prose cli model list` shows each model's status. @@ -199,8 +199,8 @@ A command that spends money, publishes, deletes or cannot be undone needs invalidates the old one now), `details.plannedRequest` (the method, what the request does as `description`, its non-secret inputs as `summary`: model, programRef, inputKeys, amount_cents, slug; and for `cli run submit`, `cli program draft` and a paid -`cli job create` the service's hold quote for the options or spec given, and -for `cli program draft` the parameter-free quote), +`cli job create` the service's hold quote for that program and the options or +spec given, and for `cli program draft` the parameter-free quote), `details.confirmArgv` and `details.previewArgv`. `--preview` prints the same plan, exits 0 and changes nothing. An unknown `--model`, an `--environment` or `--runtime` that diff --git a/cli/shared/service/help.v1.json b/cli/shared/service/help.v1.json index 84356f56..80058015 100644 --- a/cli/shared/service/help.v1.json +++ b/cli/shared/service/help.v1.json @@ -1,6 +1,6 @@ { "schema": "openprose.service-help/1", - "manifestSha256": "d5a32886217b7df3a85c7316fb8cd60fd17aa622ac12142aa0adf388c80eefb9", + "manifestSha256": "a77171cfda52072dffc7a37a9341b3f9b3a578dbdf8cf35040cdfae78711f819", "topics": { "cli": "Usage: prose [GLOBAL OPTIONS] cli [ARGUMENTS] [OPTIONS]\n\nOpenProse service and account commands. They reach the hosted OpenProse service\nand never prompt. New here? `prose cli service guide` walks through a first\nprogram, a daily schedule, scripting and costs. Commands for this machine\n(doctor, config) are listed by `prose --help`.\n\nCommands:\n run Run a program on the hosted service, then follow, read or cancel it: submit, watch, show, list, cancel, download, input, quote, share.\n program Save your programs and manage their revisions: save, list, show, delete, draft, revisions, visibility.\n example Working example programs to read and copy: list, show.\n job Run a saved program on a schedule or from a webhook: create, list, show, delete, configure, contract, deliveries, rotate-secret, update.\n wallet Balance, usage and credit: balance, usage, events, topup, redeem.\n auth Sign in, check or remove the stored key: login, status, logout.\n model Hosted models: list.\n result Published results of public programs (OWNER/SLUG); a run's own output is `cli run show` or `cli run download`: show, list, publish, unpublish.\n org Organizations, members and invitations: list, show, create, default, invitation, invite, member, rename.\n repo Repositories the service can read: list.\n package Registry packages (no source execution): fetch, list, publish, withdraw.\n service Service status, triage, the guide and the command list: triage, status, guide, capabilities, operations.\n\nExamples:\n prose cli run submit hello.prose.md --preview\n prose cli program save hello hello.prose.md --preview\n prose cli example list --json\n prose cli job create --spec-file job.json --preview\n prose cli wallet balance --json\n prose cli auth login\n prose cli model list --json\n prose cli result show exowner1/hello --latest\n prose cli org list --json\n prose cli repo list --json\n prose cli package publish my-package --organization acme --name tool --version 1.0.0 --json\n prose cli service triage --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nRun `prose cli --help` for details. `prose cli service operations --json` prints every command as JSON.\n", "cli auth": "Usage: prose [GLOBAL OPTIONS] cli auth [ARGUMENTS] [OPTIONS]\n\nAccount credentials for the OpenProse service. Every other service command\nreads the key these commands manage, or OPENPROSE_API_KEY.\n\nCommands:\n login Sign in with the GitHub device flow and store the key in the OS credential store.\n status Report whether a credential is available and valid.\n logout Remove the stored credential.\n\nExamples:\n prose cli auth login\n prose cli auth status --json\n prose cli auth logout --json\n\nGlobal options:\n --output human|json|jsonl Output mode; the same as the PROSE_OUTPUT setting.\n\nCredentials:\n OPENPROSE_API_KEY API key; a non-empty value wins over the stored key.\n\nDevice flow: `cli auth login` prints a one-time code and https://github.com/login/device\non stderr, never the key, and never opens a browser. A person approves the code\nthere within 15 minutes. An agent without a person sets the variable instead.\n\nRun `prose cli auth --help` for details. `prose cli service operations --json` prints every command as JSON.\n", @@ -64,7 +64,7 @@ "cli run download": "Usage: prose [GLOBAL OPTIONS] cli run download [OPTIONS]\n\nDownload every file listed in a run manifest into a new directory (./RUN_ID unless --output-dir).\n\nArguments:\n RUN_ID Run id (run_ and 64 hex digits), or latest for your newest run.\n\nOptions:\n --output-dir DIR Destination directory; it must not exist. Default: ./RUN_ID.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run download run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c\n prose cli run download run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c --output-dir run-output\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", "cli run input": "Usage: prose [GLOBAL OPTIONS] cli run input [OPTIONS]\n\nSend an instruction to a live run. The instruction id makes retries idempotent. Without a live session on this machine the run record is read first: an ended run is refused as SERVICE_WRITE_CONFLICT (not retryable).\n\nArguments:\n RUN_ID Run id (run_...).\n TEXT Instruction text, 1 to 4000 characters.\n\nOptions:\n --id UUID Instruction id to reuse when retrying; a new UUID is minted otherwise.\n --session UUID Live session to use instead of the one recorded in the local run journal.\n --yes Confirm this operation; required because the live run acts on the instruction as soon as it arrives, and it cannot be taken back.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run input run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c 'Also write a haiku.' --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli run list": "Usage: prose [GLOBAL OPTIONS] cli run list [OPTIONS]\n\nList runs, newest first, one page at a time.\n\nOptions:\n --limit N Page size, 1 to 200. Default: 20.\n --before CURSOR Opaque cursor from a previous result's nextBefore.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run list --limit 10 --json\n\nOutput: openprose.service-operation/1 (--json).\nPaging: pass the result's nextBefore to --before for the next page.\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", - "cli run quote": "Usage: prose [GLOBAL OPTIONS] cli run quote [FILE] [OPTIONS]\n\nReport 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.\n\nArguments:\n FILE 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.\n\nOptions:\n --from [OWNER/]SLUG[@REV] 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.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads UTF-8 text of at most 1 MiB. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it. Repeatable.\n --inputs-file FILE JSON object of string inputs. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.\n --model MODEL Hosted model id (see `cli model list`). Sent with the quote: the hold depends on it. Default: the service's default_model (`cli model list`).\n --reasoning-effort EFFORT Reasoning effort supported by the model. Sent with the quote: the hold depends on it. One of: minimal, low, medium, high, xhigh.\n --environment ENV Execution environment offered by the service (for example builtin or linux). Sent with the quote: the hold depends on it. Default: the service's default environment.\n --runtime RUNTIME Runtime offered by the service. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it. Default: the service's default runtime.\n --repo OWNER/NAME[@BRANCH] Read-only repository context. Any --repo or --commit-output sends repositories=1 with the quote: a bound repository adds to the hold. Repeatable.\n --commit-output OWNER/NAME[@BRANCH]\n 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.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run quote --json\n prose cli run quote hello.prose.md --input topic=cats --json\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", + "cli run quote": "Usage: prose [GLOBAL OPTIONS] cli run quote [FILE] [OPTIONS]\n\nReport 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. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). 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.\n\nArguments:\n FILE Program file, or - for standard input, as `cli run submit` takes it. The service prices this program, including its own run settings and declared tools.\n\nOptions:\n --from [OWNER/]SLUG[@REV] 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 service prices the latest revision. The service prices this program, including its own run settings and declared tools.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads UTF-8 text of at most 1 MiB. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it. Repeatable.\n --inputs-file FILE JSON object of string inputs. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it.\n --model MODEL Hosted model id (see `cli model list`). Sent with the quote; overrides the program's own setting. Default: the service's default_model (`cli model list`).\n --reasoning-effort EFFORT Reasoning effort supported by the model. Sent with the quote; overrides the program's own setting. One of: minimal, low, medium, high, xhigh.\n --environment ENV Execution environment offered by the service (for example builtin or linux). Sent with the quote; overrides the program's own setting. Default: the service's default environment.\n --runtime RUNTIME Runtime offered by the service. Accepted so a quote names the same command as `cli run submit`; the hold does not depend on it. Default: the service's default runtime.\n --repo OWNER/NAME[@BRANCH] Read-only repository context. Sent with the quote: a bound repository adds to the hold. Repeatable.\n --commit-output OWNER/NAME[@BRANCH]\n Writable repository that receives the run's commit. Sent with the quote: a bound repository adds to the hold.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run quote --json\n prose cli run quote hello.prose.md --input topic=cats --json\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error; 24 interrupted.\n", "cli run share": "Usage: prose [GLOBAL OPTIONS] cli run share [OPTIONS]\n\nCreate a public link to a run's outputs. Anyone with the link can open it for 24 hours, and it cannot be revoked.\nTo give specific people access instead, invite them to your organization (`cli org invite`).\n\nArguments:\n RUN_ID Run id (run_ and 64 hex digits), or latest for your newest run.\n\nOptions:\n --yes Confirm this operation; required because it creates a public link to the run's outputs that anyone who has it can open for 24 hours, and it cannot be revoked.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run share run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c --yes\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 24 interrupted.\n", "cli run show": "Usage: prose [GLOBAL OPTIONS] cli run show [OPTIONS]\n\nShow a run manifest (status, files, price), or one output file with --file. Signed file URLs are never printed.\nThe manifest has no final response text. Read the run's outputs with `cli run show RUN_ID --file outputs/result.json` or `cli run download RUN_ID --output-dir DIR`; replay the stream, whose terminal event carries the response, with `cli run watch RUN_ID --after 0` (runs submitted from this machine, or pass --session).\n\nArguments:\n RUN_ID Run id (run_ and 64 hex digits), or latest for your newest run.\n\nOptions:\n --file PATH Output file path exactly as listed in the manifest.\n --output-file FILE Write the bytes to FILE, which must not exist, instead of including them in the result.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run show run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c --json\n prose cli run show run_4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c4f1c2d3e4f5a6b7c --file outputs/result.json\n prose cli run show latest --json\n\nOutput: openprose.service-operation/1 (--json).\nExit codes: 0 success; 2 invalid invocation or configuration; 10 service or credential error, or no such run or --file (SERVICE_RESOURCE_NOT_FOUND); 24 interrupted.\n", "cli run submit": "Usage: prose [GLOBAL OPTIONS] cli run submit [FILE] [OPTIONS]\n\nRun a program on the hosted service and stream it until it finishes, detaches or the deadline passes.\nInputs: --input KEY=VALUE overrides the same key from --inputs-file.\nA --wait shorter than the run exits 21 with details.resumeArgv: the run continues, and that command follows it again. Never submit again to resume; that starts a second paid run.\n\nArguments:\n FILE Program file, or - for standard input. Omit when --from is given.\n\nOptions:\n --from [OWNER/]SLUG[@REV] 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.\n --input KEY=VALUE|KEY=@FILE\n Program input; @FILE reads UTF-8 text of at most 1 MiB. It overrides the same key from --inputs-file. Repeatable.\n --inputs-file FILE JSON object of string inputs; an --input of the same key wins.\n --model MODEL Hosted model id (see `cli model list`). Default: the service's default_model (`cli model list`).\n --reasoning-effort EFFORT Reasoning effort supported by the model. One of: minimal, low, medium, high, xhigh.\n --environment ENV Where the run executes (for example builtin: file tools only; linux: a shell without network access). Web access is through the browser:interact tool. Default: the service's default environment.\n --runtime RUNTIME Runtime offered by the service. Default: the service's default runtime.\n --repo OWNER/NAME[@BRANCH] Read-only repository context. Repeatable.\n --commit-output OWNER/NAME[@BRANCH]\n Writable repository that receives the run's commit.\n --detach Return as soon as the run id is known.\n --wait DURATION Stop following after this long and exit 21 (for example 90s, 10m, 2h; maximum 6h). The run continues. Default: 30m.\n --session UUID Use this session UUID instead of a new one (recovery only).\n --yes Confirm this operation; required because it starts a paid run: a hold (money set aside from the wallet, not the price) is reserved now, and the run's price is charged when it settles.\n --preview Print the planned request of a mutation and send nothing.\n --json Print one JSON result; the same as the global --output json.\n --help Show help for this command.\n\nExamples:\n prose cli run submit hello.prose.md --preview\n prose cli run submit hello.prose.md --yes\n prose cli run submit --from exowner1/hello --input topic=rain --yes --detach --json\n\nOutput: openprose.service-operation/1 (--json) or openprose.service-event/1 lines (--output jsonl). The run id is result.runId (and result.run.run_id once the run ends); the answer is result.run.response.\nExit codes: 0 success; 2 invalid invocation, configuration or CONFIRMATION_REQUIRED; 10 service or credential error; 21 deadline or detached: the run id is known and the run continues (resume with details.resumeArgv); 22 run failed, or submission ambiguous (recover with details.resumeArgv: the same session with --detach, never a new run); 24 cancelled on the service.\n", @@ -87,7 +87,7 @@ "contract": "service/1", "manifest": { "schema": "openprose.service-operations/1", - "sha256": "c3aef4f39d4fa26f6af3334e311806c5aedca0cb9934c47c325678467454edd5", + "sha256": "87e68b5f8f2e0125f7f6afc548a2d888bdd9c645261c59935fd4e3e7dc388bbb", "argv": [ "cli", "service", @@ -501,7 +501,7 @@ "run", "quote" ], - "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.", + "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. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). 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.", "effect": "read", "confirm": false, "examples": [ diff --git a/cli/shared/service/operations.v1.json b/cli/shared/service/operations.v1.json index e87dd2c9..d1c91ee2 100644 --- a/cli/shared/service/operations.v1.json +++ b/cli/shared/service/operations.v1.json @@ -3,7 +3,7 @@ "contract": "service/1", "interactionsSource": { "file": "service-interactions.v1.json", - "sha256": "4eb59d64fc7f2392a1112741ebabdaa4628454b9fce0737dc94eeb20f04e5108" + "sha256": "2a272f2afc0ef4c87431b947f71af6ce5a7fe8dc731f96e106c25e156a077a01" }, "grammar": { "usage": "prose [--output human|json|jsonl] cli [ARGUMENTS] [OPTIONS]", @@ -1208,7 +1208,8 @@ "invalid_account": "SERVICE_REQUEST_REJECTED", "reserved_slug": "SERVICE_REQUEST_REJECTED", "immutable_organization_identity": "SERVICE_REQUEST_REJECTED", - "organization_forbidden": "SERVICE_REQUEST_REJECTED" + "organization_forbidden": "SERVICE_REQUEST_REJECTED", + "program_not_found": "SERVICE_RESOURCE_NOT_FOUND" }, "routeOverrides": [ { @@ -1954,16 +1955,17 @@ ], "contract": "service/1", "feature": "runs", - "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.", + "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. With FILE or --from the service prices that program, including its own run settings and declared tools; --model, --reasoning-effort, --environment, --repo and --commit-output override them. `basis` names where each value came from (request, program or default). 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.", "interaction": "run.quote", "interactions": [ "health.read", - "run.quote" + "run.quote", + "program.revisions" ], "arguments": [ { "name": "FILE", - "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.", + "description": "Program file, or - for standard input, as `cli run submit` takes it. The service prices this program, including its own run settings and declared tools.", "required": false, "variadic": false } @@ -1972,7 +1974,7 @@ { "name": "--from", "value": "[OWNER/]SLUG[@REV]", - "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.", + "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 service prices the latest revision. The service prices this program, including its own run settings and declared tools.", "repeatable": false, "required": false }, @@ -1993,7 +1995,7 @@ { "name": "--model", "value": "MODEL", - "description": "Hosted model id (see `cli model list`). Sent with the quote: the hold depends on it.", + "description": "Hosted model id (see `cli model list`). Sent with the quote; overrides the program's own setting.", "default": "the service's default_model (`cli model list`)", "repeatable": false, "required": false @@ -2001,7 +2003,7 @@ { "name": "--reasoning-effort", "value": "EFFORT", - "description": "Reasoning effort supported by the model. Sent with the quote: the hold depends on it.", + "description": "Reasoning effort supported by the model. Sent with the quote; overrides the program's own setting.", "repeatable": false, "required": false, "choices": [ @@ -2015,7 +2017,7 @@ { "name": "--environment", "value": "ENV", - "description": "Execution environment offered by the service (for example builtin or linux). Sent with the quote: the hold depends on it.", + "description": "Execution environment offered by the service (for example builtin or linux). Sent with the quote; overrides the program's own setting.", "default": "the service's default environment", "repeatable": false, "required": false @@ -2031,14 +2033,14 @@ { "name": "--repo", "value": "OWNER/NAME[@BRANCH]", - "description": "Read-only repository context. Any --repo or --commit-output sends repositories=1 with the quote: a bound repository adds to the hold.", + "description": "Read-only repository context. Sent with the quote: a bound repository adds to the hold.", "repeatable": true, "required": false }, { "name": "--commit-output", "value": "OWNER/NAME[@BRANCH]", - "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.", + "description": "Writable repository that receives the run's commit. Sent with the quote: a bound repository adds to the hold.", "repeatable": false, "required": false } @@ -2070,13 +2072,71 @@ }, "auth": "none", "body": null, - "when": "always", + "when": "no FILE or --from", "catalogRoute": { "method": "GET", "path": "/run/quote", "auth": "none" }, "response": "run-quote" + }, + { + "interaction": "run.quote", + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "{program_ref}", + "model": "{model}", + "reasoning_effort": "{reasoning_effort}", + "environment": "{environment}", + "repositories": "1" + }, + "auth": "optional", + "body": null, + "when": "--from", + "catalogRoute": { + "method": "GET", + "path": "/run/quote", + "auth": "none" + }, + "response": "run-quote" + }, + { + "interaction": "run.quote", + "method": "POST", + "path": "/run/quote", + "query": { + "environment": "{environment}", + "runtime": "{runtime}", + "repositories": "{repositories}", + "output_repository": "{output_repository}" + }, + "auth": "optional", + "headers": { + "Content-Type": "application/json" + }, + "body": "json", + "when": "FILE", + "catalogRoute": { + "method": "POST", + "path": "/run/quote", + "auth": "none" + }, + "response": "run-quote" + }, + { + "interaction": "program.revisions", + "method": "GET", + "path": "/programs/{slug}/revisions", + "auth": "bearer", + "body": null, + "when": "--from SLUG or @N", + "catalogRoute": { + "method": "GET", + "path": "/programs/{slug}/revisions", + "auth": "api_key_or_session_token" + }, + "response": "program-revisions" } ], "effect": "read", @@ -2256,21 +2316,24 @@ }, { "interaction": "run.quote", - "method": "GET", + "method": "POST", "path": "/run/quote", "query": { - "model": "{model}", - "reasoning_effort": "{reasoning_effort}", "environment": "{environment}", - "repositories": "1" + "runtime": "{runtime}", + "repositories": "{repositories}", + "output_repository": "{output_repository}" }, - "auth": "none", - "body": null, + "auth": "bearer", + "headers": { + "Content-Type": "application/json" + }, + "body": "json", "when": "confirmation", "catalogRoute": { - "method": "GET", + "method": "POST", "path": "/run/quote", - "auth": "none" + "auth": "api_key_or_session_token" }, "response": "run-quote" }, @@ -4653,19 +4716,20 @@ "method": "GET", "path": "/run/quote", "query": { + "program_ref": "{program_ref}", "model": "{model}", "reasoning_effort": "{reasoning_effort}", "environment": "{environment}", "repositories": "1", "job_type": "{job_type}" }, - "auth": "none", + "auth": "bearer", "body": null, "when": "confirmation", "catalogRoute": { "method": "GET", "path": "/run/quote", - "auth": "none" + "auth": "api_key_or_session_token" }, "response": "run-quote" }, diff --git a/cli/shared/service/service-interactions.source.json b/cli/shared/service/service-interactions.source.json index 43f25dd1..16e8a3fc 100644 --- a/cli/shared/service/service-interactions.source.json +++ b/cli/shared/service/service-interactions.source.json @@ -1,5 +1,5 @@ { "schema": "openprose.vendored-source/1", - "upstreamSha256": "0acb2e4fa93667b07139befa37d2ef0ea174d1f54789ddc219e877ac6a0a5ee7", - "sha256": "4eb59d64fc7f2392a1112741ebabdaa4628454b9fce0737dc94eeb20f04e5108" + "upstreamSha256": "4860e3585255044da9db54c3501f1997328406549a01863d13b3276f81623cd0", + "sha256": "2a272f2afc0ef4c87431b947f71af6ce5a7fe8dc731f96e106c25e156a077a01" } diff --git a/cli/shared/service/service-interactions.v1.json b/cli/shared/service/service-interactions.v1.json index 2640040f..4a9c27ba 100644 --- a/cli/shared/service/service-interactions.v1.json +++ b/cli/shared/service/service-interactions.v1.json @@ -497,6 +497,21 @@ { "method": "GET", "path": "/run/quote", + "auth": "api_key_or_session_token" + }, + { + "method": "GET", + "path": "/run/quote", + "auth": "none" + }, + { + "method": "POST", + "path": "/run/quote", + "auth": "api_key_or_session_token" + }, + { + "method": "POST", + "path": "/run/quote", "auth": "none" } ] diff --git a/cli/shared/tests/test_service_contract.py b/cli/shared/tests/test_service_contract.py index 10577e6e..57b99bae 100644 --- a/cli/shared/tests/test_service_contract.py +++ b/cli/shared/tests/test_service_contract.py @@ -878,7 +878,7 @@ def test_manifest_policy_matches_the_decision_record(self): for operation_id in ("service.status", "run.quote", "run.list", "run.show", "wallet.balance", "org.list", "job.contract.list"): self.assertFalse(self.operations[operation_id]["confirm"], operation_id) submit = self.operations["run.submit"] - post = next(r for r in submit["requests"] if r["method"] == "POST") + post = next(r for r in submit["requests"] if r["method"] == "POST" and r["path"] == "/run") # Always live; placement and repository intent are optional query # templates because the service reads them from the query. self.assertEqual({"live": "1", "session": "{session}", "environment": "{environment}", "runtime": "{runtime}", @@ -1043,6 +1043,15 @@ def test_vendored_export_provenance(self): with self.assertRaises(sync.SyncError): sync.validate_export((json.dumps(dict(upstream, schema="openprose.service-operations/1"), indent=2) + "\n").encode()) + # The service's public projection (/2) has no principals table; it + # projects to the same bytes, and a non-public principal is refused. + public = {key: value for key, value in upstream.items() if key != "principals"} + public["schema"] = "openprose.example-interactions/2" + self.assertEqual(data, sync.project(sync.validate_export((json.dumps(public, indent=2) + "\n").encode()), + self.manifest)) + public["interactions"][0] = dict(public["interactions"][0], principal="not-public") + with self.assertRaises(sync.SyncError): + sync.validate_export((json.dumps(public, indent=2) + "\n").encode()) # An export missing a public field is refused. missing = json.loads(data) del missing["interactions"][0]["agent"] diff --git a/docs/hosted-service-client.md b/docs/hosted-service-client.md index 68dfa2d5..d95ca942 100644 --- a/docs/hosted-service-client.md +++ b/docs/hosted-service-client.md @@ -24,7 +24,7 @@ For the workflow rather than the facts, read `prose cli service guide`: which ke ## Confirmation -Commands never prompt. A command that spends money, publishes, deletes or cannot be reversed requires `--yes`. Without it, the command exits 2 with `CONFIRMATION_REQUIRED`, says in `details.reason` what `--yes` would do (the manifest's `confirmReason`, also on the `--yes` line of the command's help: rotating a webhook secret invalidates the old one now, detaching a contract stops that program, unpublishing removes the public page), and prints the exact planned request. The plan's `summary` shows the non-secret body fields (`model`, `programRef`, `inputKeys`, `amount_cents`, `slug` and similar; never secrets, program text or input values); human output prints it as `Summary:` (cents also in dollars). For `run submit` and a paid `job create`, the plan also includes the server's hold quote for the model, effort, environment and repositories given, and `program draft` includes the parameter-free quote (`run quote` reports the same hold; it does not cover the program's own run settings or declared tools, and the price is known only after the run settles). `--preview` prints the plan for any mutation and sends nothing. Before any confirmation, `run submit` and `program draft` check `--model` against `cli model list` (an unknown name lists the three nearest and `details.suggestedArgv` retries with the nearest), `org create` checks the slug rule, and `job create|update|configure` check the spec against the closed per-type schema published as the operation's `spec` in `cli service operations --json`, reporting every problem at once in `details.violations`. A webhook job with no `program_ref` starts no runs: its plan is effect `write` with no hold. +Commands never prompt. A command that spends money, publishes, deletes or cannot be reversed requires `--yes`. Without it, the command exits 2 with `CONFIRMATION_REQUIRED`, says in `details.reason` what `--yes` would do (the manifest's `confirmReason`, also on the `--yes` line of the command's help: rotating a webhook secret invalidates the old one now, detaching a contract stops that program, unpublishing removes the public page), and prints the exact planned request. The plan's `summary` shows the non-secret body fields (`model`, `programRef`, `inputKeys`, `amount_cents`, `slug` and similar; never secrets, program text or input values); human output prints it as `Summary:` (cents also in dollars). For `run submit` and a paid `job create`, the plan also includes the server's hold quote for that program, including its own run settings and declared tools, with the options or spec given overriding them, and `program draft` includes the parameter-free quote (`run quote` reports the same hold with its `basis`; the price is known only after the run settles). `--preview` prints the plan for any mutation and sends nothing. Before any confirmation, `run submit` and `program draft` check `--model` against `cli model list` (an unknown name lists the three nearest and `details.suggestedArgv` retries with the nearest), `org create` checks the slug rule, and `job create|update|configure` check the spec against the closed per-type schema published as the operation's `spec` in `cli service operations --json`, reporting every problem at once in `details.violations`. A webhook job with no `program_ref` starts no runs: its plan is effect `write` with no hold. ## Runs diff --git a/docs/service/jobs.md b/docs/service/jobs.md index 52955196..a33d86e5 100644 --- a/docs/service/jobs.md +++ b/docs/service/jobs.md @@ -69,16 +69,16 @@ printf '%s' '{"type":"webhook","delivery_mode":"test","name":"my-hook"}' \ - **A new schedule starts its first run about one second after it is created**, then every `interval_seconds`. That is why `job create` is a money operation: its plan (`--preview` or `CONFIRMATION_REQUIRED`) includes the - service's hold quote (`plannedRequest.quote`, from the anonymous - `GET /run/quote` with the spec's `model`, `reasoning_effort` and - `environment`, and `repositories=1` when it names a `repository_url` or - `context_repository_url`; the program's own run settings and declared tools - are not included), plus `job_type` (the spec's `type`) so the service can - price the model a job of that type runs on when the spec names none. A - service that does not read `job_type` prices its default run model instead, - which can differ from the default job model of a webhook or email job; give - `model` in the spec for an exact hold there. - With `--yes` no quote is read. A webhook with no + service's hold quote (`plannedRequest.quote`, from `GET /run/quote`, sent + with the key, for the job's pinned `program_ref`, so the program's own run + settings and declared tools count, with the spec's `model`, + `reasoning_effort` and `environment` overriding them and `repositories=1` + when it names a `repository_url` or `context_repository_url`), plus + `job_type` (the spec's `type`) so the service can price the model a job of + that type runs on when the spec names none. A service that does not read + `job_type` prices its default run model instead, which can differ from the + default job model of a webhook or email job; give `model` in the spec for + an exact hold there. With `--yes` no quote is read. A webhook with no `program_ref` starts no runs: its plan has effect `write`, no quote is read and no hold is shown. - `prose cli job create --help` prints minimal schedule and webhook specs and diff --git a/docs/service/runs.md b/docs/service/runs.md index 6bff6fe3..380c7fc3 100644 --- a/docs/service/runs.md +++ b/docs/service/runs.md @@ -4,7 +4,7 @@ | Command | What it does | `--yes` | | --- | --- | --- | -| `cli run quote [--model MODEL] [--reasoning-effort EFFORT] [--environment ENV] [--repo OWNER/NAME[@BRANCH]]...` | The wallet hold a run reserves while live (price-free: holds only), for the options given. `ENV` must be advertised by `/health`. | no | +| `cli run quote [FILE\|- \| --from OWNER/SLUG[@REV]] [--model MODEL] [--reasoning-effort EFFORT] [--environment ENV] [--repo OWNER/NAME[@BRANCH]]...` | The wallet hold a run reserves while live (price-free: holds only), priced for the program when one is given and for the options given. `ENV` must be advertised by `/health`. | no | | `cli run submit FILE\|- \| --from OWNER/SLUG[@REV] [options]` | Submit and stream until the run finishes, `--detach`, the `--wait` deadline, or an interrupt. | **yes** | | `cli run watch RUN_ID [--after N] [--wait DUR] [--session UUID]` | Replay events after sequence `N`, then follow live. Without a live session on this machine, report an ended run's outcome from its record. | no | | `cli run input RUN_ID TEXT [--id UUID] [--session UUID]` | Queue an instruction for a live run. A run that already ended is refused (`SERVICE_WRITE_CONFLICT`, not retryable), from any machine. | **yes** | @@ -20,10 +20,10 @@ prose cli run quote --json ```json {"schema":"openprose.service-operation/1","operation":"run.quote","interaction":"run.quote", - "result":{"environment":"builtin","hold":{"hold_usd":"1.02","hold_cents":102,"ttl_seconds":900},"holdBasis":"depends on model, reasoning effort, environment, declared tools and repositories; quoted from the options given, without the program's own run settings or declared tools; a run's price is known only after it settles","note":"Unused hold is released when the run settles."},"problem":null} + "result":{"environment":"builtin","hold":{"hold_usd":"1.02","hold_cents":102,"ttl_seconds":900},"holdBasis":"depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles","note":"Unused hold is released when the run settles."},"problem":null} ``` -The quote is the hold, not the price (human output adds a `Price:` line naming `run show`); the settled price appears in the finished run (`price_cents`). The hold depends on the model, reasoning effort, environment, declared tools and bound repositories. `run quote` sends only the `--model`, `--reasoning-effort` and `--environment` you give, plus `repositories=1` for any `--repo` or `--commit-output`; with none of them it is the parameter-free quote for the service's defaults. It never reads the program, so a program that sets its own model, effort or environment, or declares tools, can hold a different amount (`result.holdBasis` says so). An unknown `--environment` is rejected before `/run/quote` is called (`INVOCATION_INVALID`, naming the advertised environments). +The quote is the hold, not the price (human output adds a `Price:` line naming `run show`); the settled price appears in the finished run (`price_cents`). The hold depends on the model, reasoning effort, environment, declared tools and bound repositories. With FILE or `--from`, the service prices that program, including its own run settings and declared tools: `run quote FILE` posts the program (as `run submit` would, without inputs) and `run quote --from OWNER/SLUG[@REV]` names it (a bare SLUG or `@N` is resolved first). `--model`, `--reasoning-effort`, `--environment`, `--repo` and `--commit-output` override the program's values; without a program only they are sent, and with none of them it is the parameter-free quote for the service's defaults. `result.basis` names each value and its source (`request`, `program` or `default`), and `result.environment` is then the basis environment. A program the caller cannot read is `SERVICE_RESOURCE_NOT_FOUND`. An unknown `--environment` is rejected before `/run/quote` is called (`INVOCATION_INVALID`, naming the advertised environments). ## Submit @@ -31,7 +31,7 @@ The quote is the hold, not the price (human output adds a `Price:` line naming ` prose cli run submit hello.prose.md --model model-luna --yes --output jsonl ``` -- **Without `--yes`** nothing is sent: exit 2 `CONFIRMATION_REQUIRED` with `details.plannedRequest` (method, `description`, body SHA-256 and size, effect `money`; never the service route) plus `plannedRequest.quote` (the hold for the given `--model`, `--reasoning-effort`, `--environment` and repositories, as `run quote` sends them), `plannedRequest.summary` (the non-secret body fields: `model`, `reasoning_effort`, `programRef` for `--from`, and `inputKeys`, the sorted input names without values), `details.reason` (what `--yes` does: reserve the hold and start a paid run), and `details.confirmArgv` / `details.previewArgv` — the same command with `--yes` or `--preview`. `--preview` prints the same plan and exits 0. +- **Without `--yes`** nothing is sent: exit 2 `CONFIRMATION_REQUIRED` with `details.plannedRequest` (method, `description`, body SHA-256 and size, effect `money`; never the service route) plus `plannedRequest.quote` (the service's hold for the exact submission: the plan's body is sent to the quote), `plannedRequest.summary` (the non-secret body fields: `model`, `reasoning_effort`, `programRef` for `--from`, and `inputKeys`, the sorted input names without values), `details.reason` (what `--yes` does: reserve the hold and start a paid run), and `details.confirmArgv` / `details.previewArgv` — the same command with `--yes` or `--preview`. `--preview` prints the same plan and exits 0. - **Source:** a local file (at most 1 MiB of UTF-8, not blank), `-` for standard input, or `--from OWNER/SLUG[@REV]` (without `@REV` the latest revision is resolved and a pinned `program_ref` is sent). URLs are refused. - **Inputs:** `--input KEY=VALUE`, `--input KEY=@FILE` (UTF-8, at most 1 MiB) and `--inputs-file FILE` (a JSON object of strings). `--input` overrides a key from `--inputs-file`; the same `--input` key twice is an error. The whole submission is at most 8 MiB. - **Model and placement:** `--model`, `--reasoning-effort`, `--environment`, `--runtime` (see `cli model list`). A `--model` is checked against `GET /models` before the confirmation gate: a name that is not offered and not the default is `INVOCATION_INVALID` listing the three nearest offered models, with `details.suggestedArgv` retrying with the nearest; if the lookup itself fails the service decides. From 89143d0c35e0c92b3ddeb984d73ed253863cdef1 Mon Sep 17 00:00:00 2001 From: Raymond Weitekamp <19483938+rawwerks@users.noreply.github.com> Date: Wed, 7 Oct 2026 12:16:42 -0400 Subject: [PATCH 2/2] Accept job_default as a quote basis source The service reports basis.model.source job_default when a job's type picks the model; run quote passes it through. The job create plan's quote keeps job_type next to program_ref. Co-Authored-By: Claude Opus 5.5 --- cli/bun/src/core/service/runs.ts | 2 +- .../framework/help-json-group-records.json | 3 + .../framework/service-capabilities-json.json | 3 + .../framework/service-capabilities-jsonl.json | 3 + .../job-create-quote-program-not-found.json | 3 +- .../runs/quote-basis-job-default-source.json | 149 ++++++++++++++++++ .../prose-runner-core/src/service/runs.rs | 2 +- cli/shared/schemas/service/common.schema.json | 7 +- 8 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 cli/conformance/cases/service/runs/quote-basis-job-default-source.json diff --git a/cli/bun/src/core/service/runs.ts b/cli/bun/src/core/service/runs.ts index 368dcf5e..0b1a0841 100644 --- a/cli/bun/src/core/service/runs.ts +++ b/cli/bun/src/core/service/runs.ts @@ -330,7 +330,7 @@ const HOLD_COVERAGE = "quoted for the program and options given"; const HOLD_BASIS = `depends on ${HOLD_DEPENDS_ON}; ${HOLD_COVERAGE} (basis names where each came from); a run's price is known only after it settles`; /** Where a quote's hold input came from. */ -const BASIS_SOURCES = ["request", "program", "default"]; +const BASIS_SOURCES = ["request", "program", "job_default", "default"]; /** The quote basis keys, in output order. */ const BASIS_KEYS = ["model", "reasoning_effort", "environment", "tools", "repositories"] as const; diff --git a/cli/conformance/cases/service/framework/help-json-group-records.json b/cli/conformance/cases/service/framework/help-json-group-records.json index 979ebb23..a20cacd3 100644 --- a/cli/conformance/cases/service/framework/help-json-group-records.json +++ b/cli/conformance/cases/service/framework/help-json-group-records.json @@ -858,5 +858,8 @@ }, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/framework/service-capabilities-json.json b/cli/conformance/cases/service/framework/service-capabilities-json.json index e66f5429..fb1f9219 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-json.json +++ b/cli/conformance/cases/service/framework/service-capabilities-json.json @@ -1216,5 +1216,8 @@ }, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json index bb1804d6..7906de27 100644 --- a/cli/conformance/cases/service/framework/service-capabilities-jsonl.json +++ b/cli/conformance/cases/service/framework/service-capabilities-jsonl.json @@ -1222,5 +1222,8 @@ }, "schema": "openprose.service-operation/1" } + }, + "stderr": { + "text": "" } } diff --git a/cli/conformance/cases/service/jobs/job-create-quote-program-not-found.json b/cli/conformance/cases/service/jobs/job-create-quote-program-not-found.json index 22ef0fe8..c640f65d 100644 --- a/cli/conformance/cases/service/jobs/job-create-quote-program-not-found.json +++ b/cli/conformance/cases/service/jobs/job-create-quote-program-not-found.json @@ -30,7 +30,8 @@ "method": "GET", "path": "/run/quote", "query": { - "program_ref": "exowner1/jobs-example@0123456789abcdef" + "program_ref": "exowner1/jobs-example@0123456789abcdef", + "job_type": "schedule" }, "requestHeaders": { "Authorization": { diff --git a/cli/conformance/cases/service/runs/quote-basis-job-default-source.json b/cli/conformance/cases/service/runs/quote-basis-job-default-source.json new file mode 100644 index 00000000..9b174303 --- /dev/null +++ b/cli/conformance/cases/service/runs/quote-basis-job-default-source.json @@ -0,0 +1,149 @@ +{ + "id": "quote-basis-job-default-source", + "feature": "runs", + "operation": "run.quote", + "description": "A basis source of job_default (the default model for a job's type) is reported as given.", + "argv": [ + "--output", + "json", + "cli", + "run", + "quote", + "--from", + "exowner1/hello", + "--model", + "model-luna" + ], + "fixture": { + "environment": "production", + "credentials": { + "production": "rr_test_0123456789abcdef0123456789abcdef" + }, + "storeAvailable": true, + "exchanges": [ + { + "method": "GET", + "path": "/health", + "status": 200, + "body": { + "status": "ok", + "environments": { + "default": "builtin", + "available": [ + "builtin", + "linux" + ] + }, + "models": [ + "model-luna", + "model-sol" + ], + "default_model": "model-luna", + "version": "9.9.9-extra", + "capabilities": { + "web:search": { + "available": true, + "tool": "web_search" + } + } + } + }, + { + "method": "GET", + "path": "/run/quote", + "query": { + "program_ref": "exowner1/hello", + "model": "model-luna" + }, + "requestHeaders": { + "Authorization": { + "present": true + } + }, + "status": 200, + "body": { + "hold": { + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "pricing_policy_id": "pricing-policy.sha256.f114eadc02b7550e58f12c49996382b04f867cb774ac295506806f599ef1e3cd", + "note": "Unused hold is released when the run settles.", + "basis": { + "model": { + "value": "model-luna", + "source": "job_default" + }, + "reasoning_effort": { + "value": "medium", + "source": "program" + }, + "environment": { + "value": "builtin", + "source": "default" + }, + "tools": { + "value": [ + "web:search" + ], + "source": "program" + }, + "repositories": { + "value": 0, + "source": "default" + } + } + } + } + ] + }, + "exitCode": 0, + "stdout": { + "json": { + "interaction": "run.quote", + "operation": "run.quote", + "problem": null, + "result": { + "basis": { + "environment": { + "source": "default", + "value": "builtin" + }, + "model": { + "source": "job_default", + "value": "model-luna" + }, + "reasoning_effort": { + "source": "program", + "value": "medium" + }, + "repositories": { + "source": "default", + "value": 0 + }, + "tools": { + "source": "program", + "value": [ + "web:search" + ] + } + }, + "environment": "builtin", + "hold": { + "hold_cents": 102, + "hold_usd": "1.02", + "ttl_seconds": 900 + }, + "holdBasis": "depends on model, reasoning effort, environment, declared tools and repositories; quoted for the program and options given (basis names where each came from); a run's price is known only after it settles", + "note": "Unused hold is released when the run settles." + }, + "schema": "openprose.service-operation/1" + } + }, + "forbid": [ + "9.9.9-extra", + "web_search" + ], + "stderr": { + "text": "" + } +} diff --git a/cli/rust/crates/prose-runner-core/src/service/runs.rs b/cli/rust/crates/prose-runner-core/src/service/runs.rs index 78a4f7bb..2ff2358f 100644 --- a/cli/rust/crates/prose-runner-core/src/service/runs.rs +++ b/cli/rust/crates/prose-runner-core/src/service/runs.rs @@ -460,7 +460,7 @@ fn quote_basis(body: &Map) -> Result, RunnerError> let source = entry .get("source") .and_then(Value::as_str) - .filter(|source| matches!(*source, "request" | "program" | "default")) + .filter(|source| matches!(*source, "request" | "program" | "job_default" | "default")) .ok_or_else(malformed)?; let text = |value: &Value| { value diff --git a/cli/shared/schemas/service/common.schema.json b/cli/shared/schemas/service/common.schema.json index 6a5d5c8e..5e28ec78 100644 --- a/cli/shared/schemas/service/common.schema.json +++ b/cli/shared/schemas/service/common.schema.json @@ -283,7 +283,7 @@ } }, "quoteBasis": { - "description": "Where each hold input came from: the request, the program's own run settings or declared tools, or the service default.", + "description": "Where each hold input came from: the request, the program's own run settings or declared tools, the default for the job's type, or the service default.", "type": "object", "additionalProperties": false, "required": [ @@ -312,6 +312,7 @@ "enum": [ "request", "program", + "job_default", "default" ] } @@ -335,6 +336,7 @@ "enum": [ "request", "program", + "job_default", "default" ] } @@ -358,6 +360,7 @@ "enum": [ "request", "program", + "job_default", "default" ] } @@ -385,6 +388,7 @@ "enum": [ "request", "program", + "job_default", "default" ] } @@ -406,6 +410,7 @@ "enum": [ "request", "program", + "job_default", "default" ] }