Repository navigation
feat(protocol): define experimental image and decision generation endpoints - #59190
Conversation
There was a problem hiding this comment.
The contract works as described. I ran the server and client tests closest to the change (all pass) and typecheck:tsc in schema, protocol, client, plugin and server (clean). I also probed the real server: the old /api/experimental/generate now returns 404; image and decision bodies that are malformed or missing fields return 400 InvalidRequestError; valid bodies return 501 NotImplementedError.
Public API impact
- Moved (breaking):
POST /api/experimental/generate→POST /api/experimental/generate/text(operationexperimental.generate.text). Callers of the old path get 404; the generated clients are updated, and the endpoint is experimental. - Added:
POST /api/experimental/generate/image(experimental.generate.image) andPOST /api/experimental/generate/decisions(experimental.generate.decisions), with client methodsgenerate.image/generate.decisionsand their types. - Added: the
@opencode/schema/generatemodule, and the protocol errorsModelNotFoundError(404),UnsupportedModelError(422),ProviderRequestError(502) andNotImplementedError(501). - Plugins:
Context.generateis narrowed toPick<GenerateApi, "text">, the same surface as before, so plugins are unaffected.
One small concern: duplicate question names currently get through to 501 instead of the 400 the description promises (see inline).
| export const DecisionRequest = Schema.Struct({ | ||
| model: Model.Ref, | ||
| input: Schema.Array(DecisionPart).annotate(description("The evidence every question is asked about, in order")), | ||
| questions: Schema.Array(DecisionQuestion).check(Schema.isMinLength(1)), |
There was a problem hiding this comment.
The description lists duplicate question names as a 400, but nothing enforces it yet: a request with two predicate questions both named a decodes and reaches the 501. Answers are matched back to questions by name, so it may be worth adding a uniqueness check here. Then the 400 is part of the contract and in the OpenAPI spec, rather than something each future handler has to remember to check.
There was a problem hiding this comment.
The new commit addresses the earlier point: DecisionRequest now rejects duplicate question names, so they return 400 instead of reaching the 501. The added test checks this, and it passes. typecheck:tsc is clean in schema, protocol, client and server.
Public API: the only change since my last review is that experimental.generate.decisions now rejects duplicate question names with 400. The generated OpenAPI spec and clients don't change.
Nothing new to raise.
There was a problem hiding this comment.
Duplicate question names are still rejected with 400 InvalidRequestError (field: "questions"), now in the decisions handler instead of the schema, and the uniqueness rule is described on the questions field in the OpenAPI spec. That covers the earlier point. bun test test/generate.test.ts in packages/server passes, including the duplicate-name check, and typecheck:tsc is clean in schema, protocol, client and server.
Public API: nothing new since the last review. experimental.generate.decisions still returns 400 for duplicate names; only the spec's field description changed.
Nothing new to raise.
There was a problem hiding this comment.
The old POST /api/experimental/generate path is now a deprecated alias of /generate/text, so the path move is backwards compatible. Both paths share one handler and contract, and the alias is left out of the generated clients. The server generate tests (including a request on the old path) and the client promise tests pass, and typecheck:tsc is clean in protocol, client, plugin and server. Running bun run generate left nothing changed in the committed generated clients or openapi.json.
Public API: text generation is now at POST /api/experimental/generate/text, and the old path is kept as a deprecated alias (experimental.generate.textLegacy, not in the generated clients). Everything else is the same as before and all additive: experimental.generate.image and experimental.generate.decisions with their client methods, ModelNotFoundError, UnsupportedModelError, ProviderRequestError, NotImplementedError, and the @opencode/schema/generate module. Nothing breaks for existing callers.
Nothing new to raise.
There was a problem hiding this comment.
The latest commit reshapes the image and decision schemas without changing behaviour. The server generate tests and client promise tests pass, and typecheck:tsc is clean in schema, protocol, client, plugin and server. Running bun run generate left nothing changed, so the committed generated clients and openapi.json are up to date.
Public API changes in this push, all to endpoints this PR adds that haven't shipped, so nothing existing breaks:
Generate.Media(base64 or url) →Generate.File{ data, mime, name? }. URL inputs are no longer accepted.- Decision part
media→file. providerOptionsremoved from both requests.usage/providerMetadata→cost/tokens/nativeon both results.
Nothing new to raise.
There was a problem hiding this comment.
The latest commit is a small change to the decisions result schema. The server generate tests pass, typecheck:tsc is clean in schema, protocol, client, plugin and server, and running bun run generate left nothing changed in the committed generated types and openapi.json.
Public API changes in this push, both to the decisions endpoint this PR adds (not shipped yet), so nothing existing breaks:
Generate.ErrorAnswer.reason:"refusal"→ any string, with a new optionalmessage.Generate.DecisionResult.tokens:TokenUsage.Info→ optional{ input?, output? }.
Nothing new to raise.
There was a problem hiding this comment.
The latest commit makes a few small changes to the image and decision result schemas. The server generate tests pass, typecheck:tsc is clean in schema, protocol, client, plugin and server, and running bun run generate left nothing changed in the committed generated types and openapi.json.
Public API changes in this push, all to endpoints this PR adds (not shipped yet), so nothing existing breaks:
- New
Generate.Usage{ input?, output? }, now used fortokenson both results. OnImageResultthis replacesTokenUsage.Info. ImageResult.imagesitems:Generate.File→Generate.ImageOutput(addstype: "image").- Removed:
ImageResult.noticesandGenerate.Notice.
Nothing new to raise.
There was a problem hiding this comment.
The only change in this push reverts the PR's edit to V2_HTTP_API_AUDIT.md. Code, tests and generated output are unchanged, so there was nothing to run. Row 138 now lists /api/experimental/generate for experimental.generate.text, but this PR moves that operation to /api/experimental/generate/text and keeps the old path as a deprecated alias. If the revert is intentional, the audit can be updated separately.
Public API: unchanged since the last review.
Nothing else to raise.
There was a problem hiding this comment.
This push only merges dev. The PR's changes are the same as before; its two-line Generate type registration now sits in the renamed packages/client/script/generate.ts. The server generate tests and client promise tests pass, typecheck:tsc is clean in schema, protocol, client, plugin and server, and running bun run generate left nothing changed, so the committed generated clients and openapi.json still match the merged code.
Public API: unchanged since the last review.
Nothing new to raise.
88bc9e3 to
4db00b0
Compare
There was a problem hiding this comment.
The core wiring works: image and decision requests now run through the catalog models instead of returning 501. The duplicate-names point is covered in the server handler (400 before plugins load) and by @opencode/ai's Decision schema, which the plugin path also goes through. Core, server and client tests pass, typecheck:tsc is clean in ai, schema, protocol, client, plugin, core and server, and running bun run generate left nothing changed. On a real server, an unknown model returns 404 ModelNotFoundError and duplicate question names return 400. I couldn't run a real provider call (no credentials); the core tests cover that path with a stubbed HTTP client.
Public API changes in this push, all additive to what this PR introduces:
experimental.generate.imageandexperimental.generate.decisionsnow generate results instead of returning 501, andNotImplementedErroris removed from their error lists.- The plugin
Context.generatenow includesimageanddecisions, no longer justtext.
Concerns:
- A model on a provider with no credentials returns a misleading 404 (see inline).
NotImplementedErroris still exported frompackages/protocol/src/errors.tsbut nothing uses it now.- The PR description still says the routes always return 501 and that core wiring is out of scope.
b46add9 to
d6c6377
Compare
…ges and decisions
There was a problem hiding this comment.
Both new commits work as intended: NotImplementedError is gone, and text generation now uses the same errors as images and decisions. Core, server and SDK tests pass, typecheck:tsc is clean in core, protocol, client, plugin, server and sdk, and running bun run generate left nothing changed.
Thread 2 (misleading 404 when a provider has no credentials) still applies, and now covers text too. On a real server, POST /api/experimental/generate/text with openai/gpt-5 and no OpenAI key returns 404 Model not found: openai/gpt-5; before this push it returned 400 Model unavailable.
Public API: this push changes the errors of the existing text endpoint, on both /api/experimental/generate/text and the deprecated /api/experimental/generate:
- unknown model: 400
InvalidRequestError→ 404ModelNotFoundError - model of the wrong type: 400 → 422
UnsupportedModelError - provider failure: 503
ServiceUnavailableError→ 502ProviderRequestError - no model given and none available: 400 → 503
- the provider rejecting the request as invalid now returns 400
Callers that branch on the old status codes would need updating; the endpoint is experimental. Also removed: the protocol NotImplementedError (never shipped) and the core Generate.ModelSelectionError.
There was a problem hiding this comment.
This push only tightens the types of aspect and resolution; decoding is unchanged. 16:9 and 1.5:1 are accepted, wide, 1e3:1 and -1:2 are rejected, and any resolution string still passes. The core and server generate tests pass, typecheck:tsc is clean in schema, core, protocol, client and server, and running bun run generate left nothing changed.
Public API: the client's generate.image input types narrow from aspect: string to Generate.ImageAspect (${number}:${number}) and from resolution: string to Generate.ImageResolution ("1K" | "2K" | "4K" | string). The HTTP contract accepts the same requests as before, and the OpenAPI spec now names both schemas.
Nothing new to raise.
There was a problem hiding this comment.
This push makes image and decision results plain JSON before the server sends them: provider metadata (native) is copied through JSON, image tokens only appear when a count is reported, and the server's error mapping leaves out optional fields that aren't set. The core generate tests pass, including a new JSON round-trip check on the image result, the server generate tests pass, typecheck:tsc is clean in core and server, and running bun run generate left nothing changed. I couldn't reproduce the original failure, since it needs real provider metadata (no credentials here).
Public API: unchanged.
Nothing new to raise.
There was a problem hiding this comment.
This push fixes the file type of images that providers return as URLs: core now detects it from the downloaded bytes. The new core test fails on the mime value with the previous generate.ts and passes with this commit (image/png). The core generate tests pass (16) and typecheck:tsc is clean in core.
Public API: unchanged.
Nothing new to raise.
252e00d to
10ae4b7
Compare
There was a problem hiding this comment.
This push makes the image, speech and transcription protocols in @opencode/ai report no usage when a provider sends a usage object with no counts. The new tokenUsage helper is applied the same way in every protocol it touches. The new usage test, the full packages/ai suite (1933 pass) and the core generate tests pass, and typecheck:tsc is clean in ai and core.
Public API: the HTTP API and generated clients are unchanged.
Nothing new to raise.
There was a problem hiding this comment.
The Gemini change is sound: image requests stopped for safety or recitation now fail as content-policy refusals (502 content_filter) instead of a malformed-response error, and the updated ai image test checks it. The ai image tests and core generate tests pass, and typecheck:tsc is clean in ai and core.
Public API: no endpoint, schema or client changes.
One point worth confirming: image and decision calls now use the text retry policy, which also retries provider 5xx errors and failures after the request was delivered, so they may be billed more than once (see inline).
# Conflicts: # packages/ai/src/protocols/openai-images.ts # packages/core/src/models-dev.ts
There was a problem hiding this comment.
This push only merges dev, and the merge went through cleanly. In models-dev.ts, image and decision models still prefer the API matching their type, now only among the packages this build can load (from dev). dev's new schema-error.test.ts gets image and decisions on its mock service and still passes on the deprecated /api/experimental/generate path. The server and core generate tests and the core model tests pass, typecheck:tsc is clean in ai, protocol, client, plugin, core and server, and running bun run generate left nothing changed.
Public API: unchanged since the last review.
Nothing new to raise.
There was a problem hiding this comment.
Most of this push is a sound cleanup, with one regression: requests are now built with Image.request / Decision.request outside the tryRequest wrapper that Image.generate / Decision.generate used. A request that fails validation when it's built now crashes as an unhandled defect instead of returning a typed 400-style error. With a temporary core test, generate.decisions with two questions both named a returned Generate.InvalidRequestError on the previous commit and now dies with a defect. The HTTP route still returns 400 because its handler checks for duplicate names first, but plugins calling ctx.generate.decisions (or images with any construction-time validation failure) now crash (see inline). The core generate tests pass and typecheck:tsc is clean in core; the existing tests don't cover this case.
Public API: unchanged.
| if (!model) return yield* missing(input.model, "decision") | ||
| const response = yield* decisionClient | ||
| .generate( | ||
| Decision.request({ |
There was a problem hiding this comment.
Decision.request (and Image.request at line 156) throws synchronously when the request fails validation, for example on duplicate question names. Decision.generate / Image.generate wrapped construction in tryRequest, which turned that throw into an AIError InvalidRequest, and providerError mapped it to Generate.InvalidRequestError. Without that wrapper the throw becomes an unhandled defect. With a temporary test, generate.decisions with two questions named a returned Generate.InvalidRequestError before this commit and now dies with a defect. The HTTP handler checks for duplicates first, but ctx.generate.decisions from a plugin does not. Building the request inside Effect.try with the same mapping (or calling Image.generate / Decision.generate again) would restore the typed error.
There was a problem hiding this comment.
Invalid image and decision requests return a typed error again: core goes back to Image.generate / Decision.generate, and a new core test checks that duplicate question names give Generate.InvalidRequestError. Stateless generation for plugins now lives under ctx.experimental.generate (text, image, decisions), and ctx.generate is text-only and deprecated. Core generate and plugin tests and the packages/plugin suite pass, and typecheck:tsc is clean in core, plugin, sdk and server.
Public API in this push: the plugin Context gains experimental.generate (text, image, decisions); generate is marked deprecated and keeps only text. That matches the base, so existing plugins are unaffected; ctx.generate.image / ctx.generate.decisions only existed on this branch. The HTTP API is unchanged.
Nothing new to raise. The description's verification list still mentions a plugin calling ctx.generate.decisions, which no longer exists.
# Conflicts: # packages/ai/src/protocols/system-one.ts
There was a problem hiding this comment.
The earlier retry concern is addressed. Stateless text, image and decision calls now go through ModelRetry.retryOneShot, which retries only failures the provider clearly never ran (rate limits, 503/529, requests not sent or to an unreachable host, or x-should-retry: true), at most 3 times, with the existing backoff and retry-after handling. Stateless text no longer gets up to 10 retries on failures the provider may already have run, as the description notes. The core generate tests (18) cover the retry and no-retry cases and the cap, and typecheck:tsc is clean in core.
Public API: unchanged; only retry behaviour changes.
Nothing new to raise.
Problem
The server only had stateless text generation.
@opencode/aican generate images and answer decision questions, and models.dev now lists image and decision models with an opencode entrypoint for each (providers/<P>/images,providers/<P>/decisions), but nothing exposed them.Change
Two new experimental routes, plus the text route moved under the same group:
dataimages: [{ type: "image", data (base64), mime, name? }],tokens?,native?answersin question order (predicate / choice / score / error),cost?,tokens?,native?How a request runs:
All three routes share one error table:
InvalidRequestErrorModelNotFoundErrorUnsupportedModelErrorProviderRequestErrorauthentication,rate_limit,content_filter,invalid_request,overloaded,providerServiceUnavailableErrorWire types live in
@opencode/schema/generate. Plugins get all three underctx.experimental.generate(text,image,decisions). The existingctx.generate.textkeeps working as a deprecated alias.Smaller pieces:
resolveImage,resolveDecision).aspectis typed as`${number}:${number}`andresolutionas"1K" | "2K" | "4K" | string.Out of scope
chat-completions) return 422.costyet; per-image prices aren't in the model schema.Verification
bun run check, protocol and clientcheck:generated.packages/coretest/generate.test.ts: real entrypoints behind a fake HTTP client and a recorded System One response. Covers image and decision success, provider keys, a Google variant'sthinkingLevelin the request, URL images downloaded with their type detected, plain JSON results, wrong type / unknown model on all three routes, and provider error mapping.packages/servertest/generate.test.ts: 404 for unknown models, 400 for duplicate question names.gpt-image-1-mini(generate, edit,n: 2, 16:9) andgpt-image-2, Gemini, Vertex, OpenRouter, Vercel, xAI, Alibaba, Z.aijev-1.13, OpenRouter, Vercelgpt-6-luna-decisions, Baseten, TypeSafe as a custom config providerctx.generate.decisionsAdded by the opencode bot (AI-generated).
Related: #49549, #47366
Public API: adds experimental generate.text at /api/experimental/generate/text (old path kept as deprecated alias) and changes its error statuses (unknown model 400→404, provider failure 503→502); adds working generate.image and generate.decisions endpoints, client methods, errors, and plugin ctx.experimental.generate (ctx.generate.text deprecated)