@opencode/ai 0.0.0-dev-20170 → 0.0.0-dev-20180

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -475,18 +475,18 @@ const program = Effect.gen(function* () {
475
475
  Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
476
476
  call rather than being dropped, so check this table before swapping only the `model`:
477
477
 
478
- | Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
479
- | --------------------- | --- | --------- | ------------- | ------ | -------- | ------------------------- | ------------------- |
480
- | OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
481
- | Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
482
- | xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
483
- | Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
484
- | Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
485
- | Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
486
- | fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`) | ✓ |
487
- | Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
488
- | Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
489
- | Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
478
+ | Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
479
+ | --------------------- | --- | --------- | ------------- | ------ | -------- | -------------------------------- | ------------------- |
480
+ | OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
481
+ | Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
482
+ | xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
483
+ | Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
484
+ | Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
485
+ | Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
486
+ | fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`, `/multi`) | ✓ |
487
+ | Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
488
+ | Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
489
+ | Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
490
490
 
491
491
  ✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
492
492
  rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
@@ -699,7 +699,7 @@ const program = Effect.gen(function* () {
699
699
  })
700
700
  ```
701
701
 
702
- The hosted result is represented as a provider-executed tool call and tool result, and the generated image is also emitted as a first-class `media` `LLMEvent` (`response.message` then carries a `media` part). Gemini image-capable models emit the same `media` event for inline image output. Retaining `response.message` preserves the generated image for continuation on both routes.
702
+ The hosted result is represented as a provider-executed tool call and a tool result whose content carries the generated image as a file. Gemini image-capable models instead emit a first-class `media` `LLMEvent` for inline image output (`response.message` then carries a `media` part). Retaining `response.message` preserves the generated image for continuation on both routes.
703
703
 
704
704
  ## Video generation
705
705
 
@@ -840,9 +840,10 @@ Provider notes:
840
840
  - **OpenAI** streams over SSE (`stream_format: "sse"`), which is also the only place it reports token usage; `tts-1`
841
841
  and `tts-1-hd` do not support SSE and stream the raw audio body instead. `pcm` is 24 kHz 16-bit mono. `language`
842
842
  and `timestamps` are not supported.
843
- - **Gemini TTS** returns raw 16-bit PCM only (`audio/L16;codec=pcm;rate=24000`), so any `format` other than `pcm`
844
- fails typed; wrap the samples yourself. Style is directed in the text, so `instructions` and `speed` fail typed.
845
- Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
843
+ - **Gemini TTS** returns the provider's default output: WAV for Gemini 3.8 TTS `generate`, raw 16-bit PCM
844
+ (`audio/L16;codec=pcm;rate=24000`) otherwise. `pcm` is the only explicit `format` it accepts, and it fails typed on
845
+ Gemini 3.8 `generate`; the route never wraps PCM as WAV. Style is directed in the text, so `instructions` and
846
+ `speed` fail typed. Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
846
847
  `providerOptions.speechConfig.multiSpeakerVoiceConfig`.
847
848
  - **ElevenLabs** requires `voice` (the path voice id) and authenticates with `xi-api-key`. `format` maps to the
848
849
  `output_format` query parameter (`mp3_44100_128`, `pcm_24000`, `wav_24000`, `opus_48000_64`);
@@ -944,6 +945,7 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
944
945
  - **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
945
946
  - **`Media`** — the shared asset type (`Media.Asset`, `Media.Source`) and constructors used by messages, tool results, and media requests.
946
947
  - **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
948
+ - **`Video.request` / `generate` / `stream` / `start` / `resume`** — queued video generation through a provider-neutral request; `VideoClient` is its Effect service and layer.
947
949
  - **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
948
950
  - **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
949
951
  - **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
@@ -44,6 +44,7 @@ export type Event = Observation | {
44
44
  readonly id: string;
45
45
  readonly status: Status;
46
46
  };
47
+ export declare const isTerminal: (status: Status) => boolean;
47
48
  export declare class Generation<Response> {
48
49
  readonly route: Route<Response>;
49
50
  /** Route-owned serializable JSON; pass it to the modality's `resume` from another process. */
@@ -64,9 +65,10 @@ export declare class Generation<Response> {
64
65
  await(options?: AwaitOptions): Effect.Effect<Response, AIError>;
65
66
  cancel(): Effect.Effect<void, AIError>;
66
67
  /**
67
- * Status observations as a stream, ending after the first terminal observation. Each poll is bounded by the time
68
- * remaining until `poll.timeout`, so a hung status request fails the stream instead of stalling it. (`Stream.interruptWhen`
69
- * would express this directly but deadlocks under `TestClock` when the source completes while the timer sleeps.)
68
+ * Status observations as a stream, ending after the first terminal observation. Each poll and each sleep between polls
69
+ * is bounded by the time remaining until `poll.timeout`, so a hung status request or a long interval fails the stream at
70
+ * the deadline instead of stalling it. (`Stream.interruptWhen` would express this directly but deadlocks under
71
+ * `TestClock` when the source completes while the timer sleeps.)
70
72
  */
71
73
  events(options?: AwaitOptions): Stream.Stream<Event, AIError>;
72
74
  private event;
@@ -14,6 +14,7 @@ export const ProgressEvent = Schema.Struct({
14
14
  progress: Schema.optional(Schema.Number),
15
15
  }).annotate({ identifier: "Generation.Event.Progress" });
16
16
  const TERMINAL = new Set(["completed", "failed", "cancelled", "expired"]);
17
+ export const isTerminal = (status) => TERMINAL.has(status);
17
18
  export class Generation {
18
19
  route;
19
20
  token;
@@ -40,7 +41,7 @@ export class Generation {
40
41
  };
41
42
  }
42
43
  get terminal() {
43
- return TERMINAL.has(this.status);
44
+ return isTerminal(this.status);
44
45
  }
45
46
  refresh() {
46
47
  return this.route.status.pipe(Effect.map((snapshot) => new Generation(this.route, this.token, snapshot)));
@@ -61,9 +62,10 @@ export class Generation {
61
62
  return this.route.cancel ?? Effect.void;
62
63
  }
63
64
  /**
64
- * Status observations as a stream, ending after the first terminal observation. Each poll is bounded by the time
65
- * remaining until `poll.timeout`, so a hung status request fails the stream instead of stalling it. (`Stream.interruptWhen`
66
- * would express this directly but deadlocks under `TestClock` when the source completes while the timer sleeps.)
65
+ * Status observations as a stream, ending after the first terminal observation. Each poll and each sleep between polls
66
+ * is bounded by the time remaining until `poll.timeout`, so a hung status request or a long interval fails the stream at
67
+ * the deadline instead of stalling it. (`Stream.interruptWhen` would express this directly but deadlocks under
68
+ * `TestClock` when the source completes while the timer sleeps.)
67
69
  */
68
70
  events(options) {
69
71
  if (this.terminal)
@@ -71,11 +73,16 @@ export class Generation {
71
73
  const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT);
72
74
  return Stream.unwrap(Clock.currentTimeMillis.pipe(Effect.map((start) => {
73
75
  const deadline = start + Duration.toMillis(timeout);
74
- const refresh = Clock.currentTimeMillis.pipe(Effect.flatMap((now) => this.refresh().pipe(Effect.timeoutOrElse({
75
- duration: Duration.millis(Math.max(0, deadline - now)),
76
- orElse: () => this.timeoutError(timeout),
77
- }))));
78
- return Stream.fromEffectSchedule(refresh, this.schedule(options?.poll)).pipe(Stream.takeUntil((generation) => generation.terminal), Stream.map((generation) => generation.event()));
76
+ // Fail before polling once the deadline has passed: a fast status request could otherwise win the zero-budget
77
+ // race and schedule another zero-delay poll.
78
+ const refresh = Clock.currentTimeMillis.pipe(Effect.flatMap((now) => now >= deadline
79
+ ? this.timeoutError(timeout)
80
+ : this.refresh().pipe(Effect.timeoutOrElse({
81
+ duration: Duration.millis(deadline - now),
82
+ orElse: () => this.timeoutError(timeout),
83
+ }))));
84
+ const schedule = this.schedule(options?.poll).pipe(Schedule.modifyDelay((meta) => Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now))))));
85
+ return Stream.fromEffectSchedule(refresh, schedule).pipe(Stream.takeUntil((generation) => generation.terminal), Stream.map((generation) => generation.event()));
79
86
  })));
80
87
  }
81
88
  event() {
@@ -64,8 +64,10 @@ const fromRequest = Effect.fn("AssemblyAITranscription.fromRequest")(function* (
64
64
  language_code: request.language,
65
65
  language_detection: request.language === undefined ? true : undefined,
66
66
  prompt: request.prompt,
67
- // Turn-level `utterances`, the only segments AssemblyAI returns, require speaker labels.
68
- speaker_labels: request.diarize === true || request.timestamps === "segment" ? true : undefined,
67
+ // Turn-level `utterances`, the only segments AssemblyAI returns, and `speakers_expected` require speaker labels.
68
+ speaker_labels: request.diarize === true || request.timestamps === "segment" || request.speakers !== undefined
69
+ ? true
70
+ : undefined,
69
71
  speakers_expected: request.speakers,
70
72
  }, request.providerOptions, request.http?.body) ?? {});
71
73
  });
@@ -12,21 +12,27 @@ export type BlackForestLabsImageOptions = {
12
12
  readonly steps?: number;
13
13
  } & Record<string, unknown>;
14
14
  export type Request = ImageRequestFor<BlackForestLabsImageOptions>;
15
- /** Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. */
15
+ /**
16
+ * Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. BFL reports the
17
+ * credit cost on submit, so it rides on the token; it is optional so tokens persisted before it existed still decode.
18
+ */
16
19
  export declare const Token: Schema.Struct<{
17
20
  readonly id: Schema.String;
18
21
  readonly pollingURL: Schema.String;
22
+ readonly cost: Schema.optionalKey<Schema.Number>;
19
23
  }>;
20
24
  export type Token = Schema.Schema.Type<typeof Token>;
21
25
  export declare const protocol: MediaProtocol.Queued<Request, ImageResponse, {
22
26
  readonly id: string;
23
27
  readonly pollingURL: string;
28
+ readonly cost?: number | undefined;
24
29
  }>;
25
30
  export declare const model: (input: MediaRoute.ModelInput) => ImageModel<BlackForestLabsImageOptions>;
26
31
  export declare const BlackForestLabsImages: {
27
32
  readonly protocol: MediaProtocol.Queued<Request, ImageResponse, {
28
33
  readonly id: string;
29
34
  readonly pollingURL: string;
35
+ readonly cost?: number | undefined;
30
36
  }>;
31
37
  readonly model: (input: MediaRoute.ModelInput) => ImageModel<BlackForestLabsImageOptions>;
32
38
  };
@@ -11,11 +11,19 @@ export const DEFAULT_BASE_URL = "https://api.bfl.ai";
11
11
  // ---------------------------------------------------------------------------
12
12
  // 2. Token and response schemas
13
13
  // ---------------------------------------------------------------------------
14
- /** Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. */
15
- export const Token = Schema.Struct({ id: Schema.String, pollingURL: Schema.String });
14
+ /**
15
+ * Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. BFL reports the
16
+ * credit cost on submit, so it rides on the token; it is optional so tokens persisted before it existed still decode.
17
+ */
18
+ export const Token = Schema.Struct({
19
+ id: Schema.String,
20
+ pollingURL: Schema.String,
21
+ cost: Schema.optionalKey(Schema.Number),
22
+ });
16
23
  const StartResponse = Schema.Struct({
17
24
  id: Schema.String,
18
25
  polling_url: Schema.String,
26
+ cost: optionalNull(Schema.Number),
19
27
  });
20
28
  const Result = Schema.Struct({
21
29
  id: Schema.String,
@@ -93,7 +101,11 @@ const fromRequest = Effect.fn("BlackForestLabsImages.fromRequest")(function* (re
93
101
  // 6. Response decoding
94
102
  // ---------------------------------------------------------------------------
95
103
  const decodeStart = route.decodeStarted(StartResponse, (value) => ({
96
- token: { id: value.id, pollingURL: value.polling_url },
104
+ token: {
105
+ id: value.id,
106
+ pollingURL: value.polling_url,
107
+ ...(value.cost === undefined || value.cost === null ? {} : { cost: value.cost }),
108
+ },
97
109
  snapshot: { id: value.id, status: "queued" },
98
110
  }));
99
111
  const decodeDocument = route.decodeJson(Result);
@@ -112,10 +124,12 @@ const decodeResult = Effect.fn("BlackForestLabsImages.decodeResult")(function* (
112
124
  if (status !== "completed" || document.result === undefined || document.result === null)
113
125
  return yield* output.invalid(`${route.name} generation ${context.token.id} has no result`);
114
126
  const { sample, seed, prompt, ...rest } = document.result;
127
+ // A settled `cost` on the result supersedes the submit-time cost carried on the token.
128
+ const cost = document.cost ?? context.token.cost;
115
129
  return new ImageResponse({
116
130
  // `sample` is a signed URL that expires 10 minutes after the result is ready, so it is downloaded now.
117
131
  images: [yield* context.materialize(Media.url(sample))],
118
- usage: document.cost === undefined || document.cost === null ? undefined : { type: "credits", credits: document.cost },
132
+ usage: cost === undefined ? undefined : { type: "credits", credits: cost },
119
133
  providerMetadata: {
120
134
  bfl: { id: context.token.id, seed: seed ?? undefined, prompt: prompt ?? undefined, ...rest },
121
135
  },
@@ -38,6 +38,9 @@ const queryParameters = (request) => {
38
38
  });
39
39
  };
40
40
  const fromRequest = Effect.fn("DeepgramSpeech.fromRequest")(function* (request) {
41
+ // Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
42
+ if (request.timestamps === true)
43
+ return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`);
41
44
  if (request.format !== undefined &&
42
45
  FORMATS[request.format] === undefined &&
43
46
  request.providerOptions?.encoding === undefined)
@@ -74,7 +77,7 @@ const finish = (state, context) => {
74
77
  // 7. Protocol and route
75
78
  // ---------------------------------------------------------------------------
76
79
  export const protocol = MediaProtocol.stream(route, {
77
- unsupported: ["voice", "language", "instructions", "timestamps"],
80
+ unsupported: ["voice", "language", "instructions"],
78
81
  body: { from: fromRequest },
79
82
  frames: (bytes) => bytes,
80
83
  initial: () => ({ chunks: [] }),
@@ -25,7 +25,7 @@ const QueueResult = Schema.StructWithRest(Schema.Struct({
25
25
  // 5. Request body construction
26
26
  // ---------------------------------------------------------------------------
27
27
  const sizing = (model) => {
28
- if (/^fal-ai\/(nano-banana|flux-pro\/v1\.1-ultra)/.test(model))
28
+ if (/^fal-ai\/(nano-banana|flux-pro\/(v1\.1-ultra|kontext))/.test(model))
29
29
  return "aspect_ratio";
30
30
  if (model.startsWith("fal-ai/flux"))
31
31
  return "image_size";
@@ -40,16 +40,17 @@ const validate = (request) => {
40
40
  return Effect.fail(route.unsupported("media.size", `${id} sizes by aspectRatio`));
41
41
  if (request.aspectRatio !== undefined && field === "image_size")
42
42
  return Effect.fail(route.unsupported("media.aspectRatio", `${id} sizes by size (image_size)`));
43
- if ((request.images?.length ?? 0) > 1 && !isEdit(id))
44
- return Effect.fail(route.unsupported("media.images", `${id} takes one image_url; use an /edit endpoint for several images`));
43
+ if ((request.images?.length ?? 0) > 1 && !takesImageList(id))
44
+ return Effect.fail(route.unsupported("media.images", `${id} takes one image_url; use an /edit or /multi endpoint for several images`));
45
45
  return Effect.void;
46
46
  };
47
- // `/edit` endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one `image_url` (beside `mask_url`).
48
- const isEdit = (model) => model.endsWith("/edit");
47
+ // `/edit` and `/multi` (Kontext) endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one
48
+ // `image_url` (beside `mask_url`).
49
+ const takesImageList = (model) => model.endsWith("/edit") || model.endsWith("/multi");
49
50
  const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request) {
50
51
  yield* validate(request);
51
52
  const images = yield* Effect.forEach(request.images ?? [], (image) => FalQueue.mediaUrl(image, route.name));
52
- const edit = isEdit(request.model.id);
53
+ const list = takesImageList(request.model.id);
53
54
  return MediaProtocol.json(mergeJsonRecords({
54
55
  prompt: request.prompt,
55
56
  num_images: request.n,
@@ -57,8 +58,8 @@ const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request) {
57
58
  image_size: request.size === undefined ? undefined : MediaInput.dimensions(request.size),
58
59
  aspect_ratio: request.aspectRatio,
59
60
  output_format: request.format,
60
- image_urls: edit && images.length > 0 ? images : undefined,
61
- image_url: edit ? undefined : images[0],
61
+ image_urls: list && images.length > 0 ? images : undefined,
62
+ image_url: list ? undefined : images[0],
62
63
  mask_url: request.mask === undefined ? undefined : yield* FalQueue.mediaUrl(request.mask, route.name),
63
64
  }, request.providerOptions, request.http?.body) ?? {});
64
65
  });
@@ -74,10 +75,12 @@ const decodeResult = Effect.fn("FalImages.decodeResult")(function* (response, co
74
75
  // With the safety checker on, flagged images come back blacked out rather than omitted.
75
76
  const flagged = (has_nsfw_concepts ?? []).flatMap((value, index) => (value ? [index] : []));
76
77
  return new ImageResponse({
77
- images: images.map((image) => Media.url(image.url, {
78
- mediaType: image.content_type ?? undefined,
79
- info: { width: image.width ?? undefined, height: image.height ?? undefined },
80
- })),
78
+ images: images.map((image) => {
79
+ const info = { width: image.width ?? undefined, height: image.height ?? undefined };
80
+ // `sync_mode: true` returns data URIs instead of hosted URLs.
81
+ return (Media.parseDataUrl(image.url, { info }) ??
82
+ Media.url(image.url, { mediaType: image.content_type ?? undefined, info }));
83
+ }),
81
84
  notices: flagged.length === 0
82
85
  ? undefined
83
86
  : flagged.map((index) => ({
@@ -20,6 +20,9 @@ const decodeChunk = route.decodeFrame(GenerateContentChunk);
20
20
  // 5. Request body construction
21
21
  // ---------------------------------------------------------------------------
22
22
  const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request) {
23
+ // Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
24
+ if (request.timestamps === true)
25
+ return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`);
23
26
  if (request.format === "pcm" && request.mode === "generate" && /^gemini-3\.8-.*-tts(?:-|$)/.test(request.model.id))
24
27
  return yield* route.unsupported("media.format", `${route.name} returns WAV by default for Gemini 3.8 TTS unary requests; omit the format to accept it`);
25
28
  if (request.format !== undefined && request.format !== "pcm")
@@ -66,7 +69,7 @@ const finish = (state, context) => {
66
69
  // 7. Protocol and route
67
70
  // ---------------------------------------------------------------------------
68
71
  export const protocol = MediaProtocol.stream(route, {
69
- unsupported: ["instructions", "speed", "timestamps"],
72
+ unsupported: ["instructions", "speed"],
70
73
  body: { from: fromRequest },
71
74
  frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
72
75
  initial: () => ({ chunks: [] }),
@@ -111,14 +111,17 @@ const adapter = {
111
111
  name: NAME,
112
112
  restoreHostedToolItem: (item) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
113
113
  };
114
- // Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
114
+ // GPT-6 Astra, Sol, and Luna accept `configuration_update` only in standard mode (not `reasoning.mode: "pro"` or
115
+ // `-pro` slugs), and never alongside automatic `context_management` compaction.
115
116
  const supportsEffortUpdates = (request) => {
116
117
  if (request.providerOptions?.contextManagement !== undefined)
117
118
  return false;
119
+ if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning))
120
+ return false;
118
121
  const override = request.model.compatibility?.supportsEffortUpdates;
119
122
  if (override !== undefined)
120
123
  return override;
121
- return /(?:^|\/)gpt-6-astra$/i.test(request.model.id);
124
+ return /(?:^|\/)gpt-6-(?:astra|sol|luna)$/i.test(request.model.id);
122
125
  };
123
126
  const nativeImageToolInput = (tool) => {
124
127
  const native = tool.native?.openai;
@@ -31,6 +31,9 @@ const decodeEvent = route.decodeFrame(SpeechStreamEvent);
31
31
  // `sse` is not supported for `tts-1` or `tts-1-hd`; those models stream the raw audio body instead.
32
32
  const supportsSse = (model) => !/^tts-1(-hd)?(-|$)/.test(model);
33
33
  const fromRequest = Effect.fn("OpenAISpeech.fromRequest")(function* (request) {
34
+ // Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
35
+ if (request.timestamps === true)
36
+ return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`);
34
37
  return MediaProtocol.json(mergeJsonRecords({
35
38
  model: request.model.id,
36
39
  input: request.text,
@@ -80,7 +83,7 @@ const finish = (state, context) => {
80
83
  // 7. Protocol and route
81
84
  // ---------------------------------------------------------------------------
82
85
  export const protocol = MediaProtocol.stream(route, {
83
- unsupported: ["language", "timestamps"],
86
+ unsupported: ["language"],
84
87
  body: { from: fromRequest },
85
88
  frames: (bytes, context) => (isSse(context.body) ? Framing.sse.frame(bytes) : bytes),
86
89
  initial: () => ({ chunks: [], done: false }),
@@ -119,7 +119,7 @@ export const protocol = MediaProtocol.queued(route, {
119
119
  start: { body: { from: fromRequest }, decode: decodeStart },
120
120
  status: { path: taskPath, decode: decodeStatus },
121
121
  result: { path: taskPath, decode: decodeResult },
122
- cancel: { method: "DELETE", path: taskPath },
122
+ cancel: { method: "DELETE", path: taskPath, activeOnly: true },
123
123
  });
124
124
  const startPath = (request) => {
125
125
  if (request.video !== undefined)
@@ -93,6 +93,11 @@ export interface Queued<Request, Response, Token> {
93
93
  readonly cancel?: {
94
94
  readonly method: AuthInput["method"];
95
95
  readonly path: (token: Token) => string;
96
+ /**
97
+ * Fetch a fresh status first and skip the call for terminal generations, for providers whose cancel endpoint
98
+ * destroys finished work (Runway's `DELETE /v1/tasks/{id}` deletes completed tasks and their outputs).
99
+ */
100
+ readonly activeOnly?: boolean;
96
101
  };
97
102
  }
98
103
  export declare const queued: <Request, Response, Token>(route: Identity, input: Omit<Queued<Request, Response, Token>, "kind" | "id" | "provider">) => Queued<Request, Response, Token>;
@@ -5,7 +5,7 @@ import { Endpoint } from "./endpoint.js";
5
5
  import { RequestExecutorService } from "./executor-service.js";
6
6
  import { RequestExecutor } from "./executor.js";
7
7
  import { MediaProtocol } from "./media-protocol.js";
8
- import { Generation } from "../generation.js";
8
+ import { Generation, isTerminal } from "../generation.js";
9
9
  import { AIError, AIErrorReason, HttpOptions, InvalidRequestError, ProviderID, UnsupportedOperationError, mergeHttpOptions, } from "../schema/index.js";
10
10
  import { encodeJson } from "../utils/json.js";
11
11
  import { sanitizeSurrogates } from "../utils/sanitize.js";
@@ -51,13 +51,17 @@ export const queued = (input) => {
51
51
  const poll = (operation) => transport
52
52
  .call("GET", operation.path(token), http, execute)
53
53
  .pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })));
54
+ const status = poll(protocol.status);
54
55
  const cancel = protocol.cancel;
56
+ const send = cancel === undefined
57
+ ? undefined
58
+ : transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid);
55
59
  return {
56
- status: poll(protocol.status),
60
+ status,
57
61
  result: poll(protocol.result),
58
- cancel: cancel === undefined
59
- ? undefined
60
- : transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid),
62
+ cancel: send !== undefined && cancel?.activeOnly
63
+ ? status.pipe(Effect.flatMap((snapshot) => (isTerminal(snapshot.status) ? Effect.void : send)))
64
+ : send,
61
65
  };
62
66
  };
63
67
  const start = Effect.fn("MediaRoute.start")(function* (request, execute) {
@@ -192,7 +196,11 @@ const encode = (body, headers) => {
192
196
  apply: HttpClientRequest.bodyFormData(body.value),
193
197
  };
194
198
  };
195
- /** Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. */
199
+ /**
200
+ * Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. `false`
201
+ * counts as present because some booleans mean something when false (video `audio`); protocols reject opt-in
202
+ * booleans such as speech `timestamps` with `=== true` in `body.from` instead of listing them.
203
+ */
196
204
  const rejectUnsupported = (route, provider, request, unsupported) => {
197
205
  const present = (unsupported ?? []).filter((field) => {
198
206
  const value = request[field];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
- "version": "0.0.0-dev-20170",
3
+ "version": "0.0.0-dev-20180",
4
4
  "name": "@opencode/ai",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "devDependencies": {
35
35
  "@clack/prompts": "1.0.0-alpha.1",
36
36
  "@effect/platform-node": "4.0.0-rc.112",
37
- "@opencode/http-recorder": "0.0.0-dev-20170",
37
+ "@opencode/http-recorder": "0.0.0-dev-20180",
38
38
  "@tsconfig/bun": "1.0.9",
39
39
  "@types/bun": "1.4.0",
40
40
  "@typescript/native-preview": "7.0.0-dev.20251207.1",
@@ -44,7 +44,7 @@
44
44
  "@aws-sdk/credential-providers": "3.1057.0",
45
45
  "@smithy/eventstream-codec": "4.2.14",
46
46
  "@smithy/util-utf8": "4.2.2",
47
- "@opencode/schema": "0.0.0-dev-20170",
47
+ "@opencode/schema": "0.0.0-dev-20180",
48
48
  "aws4fetch": "1.0.20",
49
49
  "effect": "4.0.0-rc.112",
50
50
  "google-auth-library": "10.5.0"