Compare commits

..
Author SHA1 Message Date
rekram1-node af8eb4403c fix(tui): warn when launch directory falls back 2026-09-25 05:27:22 +00:00
Aiden Cline 5335347e80 feat(codemode): add WeakMap and WeakSet (#51257) 2026-09-25 00:15:19 -05:00
Aiden Cline 16b18dff13 Revert "fix(core): fit model limits and recover compaction overflow" (#51273) 2026-09-25 00:12:14 -05:00
Aiden Cline 61c2349cef fix(core): fit model limits and recover compaction overflow (#51238) 2026-09-25 00:11:40 -05:00
opencode-agent[bot]andBrendonovich 684721efb8 feat(app): add provider account switching (#51266)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-09-25 05:03:33 +00:00
Aiden Cline 962c14a49c fix(codemode): honor thisArg, program toString in computed keys, and ToPrimitive in String and Number arguments (#51264) 2026-09-24 23:46:15 -05:00
opencode-agent[bot]andrekram1-node 85b98e7da4 fix(tui): handle storage watcher errors after startup (#51243)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-24 20:25:06 -05:00
Aiden Cline 8061220b08 test(codemode): vendor every eligible test262 directory and bound unsupported globals (#51242) 2026-09-24 20:19:24 -05:00
Luke Parker b02cc35f13 fix(desktop): keep browser page visible under floating content (#51240) 2026-09-25 10:35:17 +10:00
Aiden Cline e23d89c9a9 fix(codemode): destructure object patterns from primitives and convert Date components through ToPrimitive (#51239) 2026-09-24 19:32:19 -05:00
e8b3e19e85 fix(tui): don't crash when fs.watch throws (e.g. ENOSPC) (#51210)
Co-authored-by: Alireza Haghdoost <haghdoost@uber.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:16:43 -05:00
Aiden Cline 5256f30957 feat(codemode): honor program valueOf and toString in operators and conversions (#50837) 2026-09-24 18:45:48 -05:00
Aiden Cline 61ecf404b9 fix(core): apply GPT verbosity defaults at request time (#51166) 2026-09-24 17:13:39 -05:00
Shoubhit Dash 92d2b1700f refactor(ai): one media client shape and route types erased over options (#51226) 2026-09-25 03:15:45 +05:30
Aiden Cline 56262121ee feat(codemode): bind this and arguments in functions, add Function.prototype.call, apply, and bind (#50831) 2026-09-24 16:34:46 -05:00
Aiden Cline e3b588e7d2 refactor(ai): apply tool schema rules once per request (#51162) 2026-09-24 16:27:30 -05:00
opencode-agent[bot]andnexxeln 1de648cb13 feat(ai): restore direct LLM input overloads (#51211)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-25 02:49:07 +05:30
Aiden Cline 03be7f385b fix(core): accept 128-character tool names (#51207) 2026-09-24 14:59:57 -05:00
Aiden Cline 8118690839 fix(core): show line counts when tool output is truncated (#51200) 2026-09-24 14:50:55 -05:00
Aiden Cline a16eedfed7 fix(codemode): make derived error constructors inherit from Error (#51045) 2026-09-24 14:03:10 -05:00
Aiden Cline e796f2f9a5 fix(core): read provider errors from plain AI SDK stream errors (#51194) 2026-09-24 13:52:32 -05:00
Aiden Cline 20610e6645 fix(ai): fit Claude thinking budgets on Bedrock Converse (#51190) 2026-09-24 13:48:39 -05:00
Aiden Cline 7f245b0968 fix(ai): fit OpenRouter reasoning budgets to the output limit (#51189) 2026-09-24 13:42:10 -05:00
Shoubhit Dash 7013e925f5 refactor(ai): keep LLM calls request-only in the promise client (#51180) 2026-09-24 23:47:42 +05:30
Aiden Cline 499c2feaa3 fix(core): cap Alibaba thinking budget variants at 64k (#51154) 2026-09-24 13:14:49 -05:00
Aiden Cline 03af821aa5 fix(core): restore the shell no-output placeholder (#51187) 2026-09-24 13:12:42 -05:00
Aiden Cline c903774556 fix(ai): fit thinking budgets to the output limit (#51157) 2026-09-24 12:58:54 -05:00
James Long 14aaf91e65 fix(tui): mark failed groups with a plain ✗ (#51175) 2026-09-24 13:52:14 -04:00
James Long c832432d89 refactor(tui): drop unused yellow alias from opencode theme (#51177) 2026-09-24 13:17:21 -04:00
opencode-agent[bot]andjlongster 1d431a80df fix(cli): reuse core declarations during typecheck (#51165)
Co-authored-by: jlongster <jlongster@users.noreply.github.com>
2026-09-24 13:16:46 -04:00
147 changed files with 4803 additions and 1689 deletions
+3 -2
View File
@@ -10,7 +10,7 @@
## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream` (later `Video`, `Speech`, `Transcription`). Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
@@ -102,7 +102,7 @@ Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
Transcription uses all three kinds (OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued): every route carries its `kind` and `TranscriptionClient` dispatches on it. `ImageRoute` is the same union; both clients dispatch through `MediaRoute.dispatch` and models compose through `composeAnyRoute`, and fal queue protocols come from `protocols/utils/fal-queue.ts`. Bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
### URL Construction
@@ -275,6 +275,7 @@ Use this order for every protocol module:
### Rules
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
- Send `tool.inputSchema` as given. `prepareRequest` applies the tool schema rules (`ToolSchemaProjection.tools`) once per request, including tools in namespaces. A protocol whose API needs a model family's rules for every model declares `sanitizer` instead of transforming schemas itself.
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
+12 -12
View File
@@ -9,15 +9,13 @@ import { OpenAI } from "@opencode/ai/providers"
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
const request = LLM.request({
model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead
system: "You are concise.",
prompt: "Say hello in one short sentence.",
generation: { maxTokens: 40 },
})
const program = Effect.gen(function* () {
const response = yield* LLM.generate(request)
const response = yield* LLM.generate({
model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead
system: "You are concise.",
prompt: "Say hello in one short sentence.",
generation: { maxTokens: 40 },
})
console.log(response.text)
})
@@ -25,7 +23,8 @@ const program = Effect.gen(function* () {
await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
```
Run `LLM.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
Run `LLM.stream(...)` instead of `generate` when you want incremental `LLMEvent`s. Both accept input or a prebuilt
`LLM.request(...)`. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
@@ -72,10 +71,11 @@ helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Ef
import { AI } from "@opencode/ai/promise"
const ai = AI.make()
const text = await ai.llm.generate({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." })
const input = { model: openai.responses("gpt-4o-mini"), prompt: "Say hello." }
const text = await ai.llm.generate(input)
const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset)
for await (const event of ai.llm.stream({ model: openai.responses("gpt-4o-mini"), prompt: "Stream hello." })) {
for await (const event of ai.llm.stream(ai.llm.request(input))) {
// LLMEvent
}
await ai.dispose()
@@ -936,7 +936,7 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
## Public API
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
- **`LLM.generate` / `LLM.stream`** — run direct input or an `LLMRequest` through `LLMClient` for one-import use.
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
+5 -4
View File
@@ -140,7 +140,7 @@ portability matrix.
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`, like every modality route. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
#### Video
@@ -215,7 +215,7 @@ const request = Speech.request({
})
const response = yield* Speech.generate(request) // SpeechResponse: audio: Media.Asset, timestamps?, usage?, providerMetadata?
yield* Speech.stream(request) // Stream<SpeechEvent>: audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
yield* Speech.stream(request) // Stream<SpeechEvent>: generation-queued | generation-progress | audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
```
Execution is `MediaProtocol.stream` for every provider: one request whose body is framed and folded by a `step`
@@ -389,7 +389,8 @@ for await (const event of generation.events({ poll: { interval: 10_000 } })) {
const video = await generation.await({ poll: { interval: 10_000 }, signal })
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
const text = await ai.llm.generate({ model, prompt })
const request = ai.llm.request({ model, prompt })
const text = await ai.llm.generate(request)
for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
@@ -419,7 +420,7 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. As with `LanguageModel`, the route type does not carry `Options`, so `ImageModel<OpenAIImageOptions>` is an `ImageModel` and client methods take plain `ImageRequestFor`. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
### Routes and protocols
+16 -85
View File
@@ -1,99 +1,30 @@
import { Context, Effect, Layer, Stream } from "effect"
import type { AwaitOptions, Generation } from "./generation.js"
import { RequestExecutor } from "./route/executor.js"
import { MediaRoute } from "./route/media.js"
import type { AIError } from "./schema/index.js"
import { Context } from "effect"
import { MediaClient } from "./media-client.js"
import {
responseEvents,
ImageOutputEvent,
ImageFinishEvent,
type ImageEvent,
type ImageModel,
type ImageOptions,
type ImageRequestFor,
type ImageResponse,
} from "./image.js"
export interface Interface {
readonly generate: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
) => Effect.Effect<ImageResponse, AIError>
readonly stream: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
) => Stream.Stream<ImageEvent, AIError>
readonly start: <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
) => Effect.Effect<Generation<ImageResponse>, AIError>
readonly resume: <Options extends ImageOptions>(
model: ImageModel<Options>,
token: unknown,
) => Effect.Effect<Generation<ImageResponse>, AIError>
}
export type Interface = MediaClient.Interface<ImageRequestFor, ImageEvent, ImageResponse>
export class ImageClientService extends Context.Service<ImageClientService, Interface>()("@opencode/ImageClient") {}
export const Service = ImageClientService
export type Service = ImageClientService
export const generate = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
): Effect.Effect<ImageResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request, options)
})
export const stream = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
options?: AwaitOptions,
): Stream.Stream<ImageEvent, AIError, Service> =>
Stream.unwrap(
Effect.gen(function* () {
const client = yield* Service
return client.stream(request, options)
}),
)
export const start = <Options extends ImageOptions>(
request: ImageRequestFor<Options>,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.start(request)
})
export const resume = <Options extends ImageOptions>(
model: ImageModel<Options>,
token: unknown,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.resume(model, token)
})
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const dispatch = MediaRoute.dispatch<ImageEvent, ImageResponse>({
modality: "image",
execute: executor.execute,
responseEvents,
})
return Service.of({
start: (request) => dispatch.start(request.model.route, request),
resume: (model, token) => dispatch.resume(model.route, model, token),
generate: (request, options) => dispatch.generate(request.model.route, request, options),
stream: (request, options) => dispatch.stream(request.model.route, request, options),
})
}),
)
export const ImageClient = {
Service,
layer,
generate,
stream,
start,
resume,
...MediaClient.make(Service, {
modality: "image",
responseEvents: (response: ImageResponse) => [
...response.images.map((image, index) => ImageOutputEvent.make({ index, image })),
ImageFinishEvent.make({
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
],
}),
} as const
+14 -65
View File
@@ -1,9 +1,8 @@
import { Effect, Schema, Stream } from "effect"
import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js"
import { Media } from "./media.js"
import { MediaModel, composeAnyRoute, tryRequest } from "./media-model.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
import { ImageClient, Service } from "./image-client.js"
@@ -11,75 +10,39 @@ import { ImageClient, Service } from "./image-client.js"
// Model
// ---------------------------------------------------------------------------
export type ImageOptions = Record<string, unknown>
export type ImageOptions = MediaModel.Options
export type ImageRoute<Options extends ImageOptions = ImageOptions> = MediaRoute.AnyRoute<
ImageRequestFor<Options>,
ImageEvent,
ImageResponse
>
export type ImageRoute = MediaRoute.AnyRoute<ImageRequestFor, ImageEvent, ImageResponse>
export class ImageModel<Options extends ImageOptions = ImageOptions> extends MediaModel<ImageRoute<Options>, Options> {
export class ImageModel<Options extends ImageOptions = ImageOptions> extends MediaModel<ImageRoute, Options> {
declare protected readonly _ImageModel: void
static make<Options extends ImageOptions = ImageOptions>(input: MediaModel.Input<ImageRoute<Options>>) {
return new ImageModel<Options>(input)
}
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
static fromRoute<Options extends ImageOptions>(
route: ImageModel.InlineRouteInput<Options>,
route: MediaModel.InlineRouteInput<ImageRequestFor<Options>, ImageResponse>,
input: MediaRoute.ModelInput,
): ImageModel<Options>
static fromRoute<Options extends ImageOptions, Frame, State>(
route: ImageModel.StreamRouteInput<Options, Frame, State>,
route: MediaModel.StreamRouteInput<ImageRequestFor<Options>, ImageEvent, Frame, State>,
input: MediaRoute.ModelInput,
): ImageModel<Options>
static fromRoute<Options extends ImageOptions, Token>(
route: ImageModel.QueuedRouteInput<Options, Token>,
route: MediaModel.QueuedRouteInput<ImageRequestFor<Options>, ImageResponse, Token>,
input: MediaRoute.ModelInput,
): ImageModel<Options>
static fromRoute<Options extends ImageOptions, Frame, State, Token>(
route: ImageModel.RouteInput<Options, Frame, State, Token>,
route: MediaModel.AnyRouteInput<ImageRequestFor<Options>, ImageEvent, ImageResponse, Frame, State, Token>,
input: MediaRoute.ModelInput,
) {
return new ImageModel<Options>({
id: input.id,
provider: route.protocol.provider,
http: input.http,
route: composeAnyRoute(route, input, collectResponse),
route: composeRoute(route, input, collectResponse) as ImageRoute,
})
}
}
export namespace ImageModel {
export type InlineRouteInput<Options extends ImageOptions = ImageOptions> = MediaModel.RouteInput<
ImageRequestFor<Options>,
MediaProtocol.Inline<ImageRequestFor<Options>, ImageResponse>
>
export type StreamRouteInput<
Options extends ImageOptions = ImageOptions,
Frame = unknown,
State = unknown,
> = MediaModel.RouteInput<
MediaProtocol.Addressed<ImageRequestFor<Options>>,
MediaProtocol.Streamed<ImageRequestFor<Options>, ImageEvent, Frame, State>
>
export type QueuedRouteInput<Options extends ImageOptions = ImageOptions, Token = unknown> = MediaModel.RouteInput<
ImageRequestFor<Options>,
MediaProtocol.Queued<ImageRequestFor<Options>, ImageResponse, Token>
>
export type RouteInput<
Options extends ImageOptions = ImageOptions,
Frame = unknown,
State = unknown,
Token = unknown,
> = MediaModel.AnyRouteInput<ImageRequestFor<Options>, ImageEvent, ImageResponse, Frame, State, Token>
}
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
expected: "Image.Model",
})
@@ -190,15 +153,6 @@ export const ImageEvent = Object.assign(imageEventTagged, {
})
export type ImageEvent = Schema.Schema.Type<typeof imageEventTagged>
export const responseEvents = (response: ImageResponse): ReadonlyArray<ImageEvent> => [
...response.images.map((image, index) => ImageOutputEvent.make({ index, image })),
ImageFinishEvent.make({
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
]
const collectResponse = (events: ReadonlyArray<ImageEvent>): Effect.Effect<ImageResponse> => {
const finish = events.find(ImageEvent.is.finish)
// Every image protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
@@ -232,36 +186,31 @@ export function request(input: ImageRequest | ImageRequestInput) {
const requestEffect = (input: ImageRequest | ImageRequestInput) => tryRequest(() => request(input))
export function generate<const Model extends ImageModel>(
input: ImageRequestInput<Model>,
input: ImageRequest | ImageRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest, options?: AwaitOptions): Effect.Effect<ImageResponse, AIError, Service>
export function generate(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.generate(request, options)))
}
export function stream<const Model extends ImageModel>(
input: ImageRequestInput<Model>,
input: ImageRequest | ImageRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<ImageEvent, AIError, Service>
export function stream(input: ImageRequest, options?: AwaitOptions): Stream.Stream<ImageEvent, AIError, Service>
export function stream(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => ImageClient.stream(request, options))))
}
/** Inline and streaming routes fail with `UnsupportedOperation`. */
export function start<const Model extends ImageModel>(
input: ImageRequestInput<Model>,
input: ImageRequest | ImageRequestInput<Model>,
): Effect.Effect<Generation<ImageResponse>, AIError, Service>
export function start(input: ImageRequest): Effect.Effect<Generation<ImageResponse>, AIError, Service>
export function start(input: ImageRequest | ImageRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.start(request)))
}
export const resume = <Options extends ImageOptions>(
model: ImageModel<Options>,
token: unknown,
): Effect.Effect<Generation<ImageResponse>, AIError, Service> => ImageClient.resume(model, token)
export const resume = (model: ImageModel, token: unknown): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
ImageClient.resume(model, token)
export const Image = {
request,
+22 -4
View File
@@ -1,5 +1,6 @@
import { Effect, JsonSchema, Schema } from "effect"
import { LLMClient, Service } from "./route/client.js"
import { Effect, JsonSchema, Schema, Stream } from "effect"
import { tryRequest } from "./media-model.js"
import { LLMClient, Service, type StreamOptions } from "./route/client.js"
import {
GenerationOptions,
HttpOptions,
@@ -35,9 +36,26 @@ export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageM
readonly http?: HttpOptions.Input
}
export const generate = LLMClient.generate
export function generate<const Model extends LanguageModel>(
input: RequestInput<Model>,
options?: StreamOptions,
): Effect.Effect<LLMResponse, AIError, Service>
export function generate(input: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service>
export function generate(input: RequestInput | LLMRequest, options?: StreamOptions) {
return requestEffect(input).pipe(Effect.flatMap((request) => LLMClient.generate(request, options)))
}
export const stream = LLMClient.stream
export function stream<const Model extends LanguageModel>(
input: RequestInput<Model>,
options?: StreamOptions,
): Stream.Stream<LLMEvent, AIError, Service>
export function stream(input: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service>
export function stream(input: RequestInput | LLMRequest, options?: StreamOptions) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => LLMClient.stream(request, options))))
}
const requestEffect = (input: RequestInput | LLMRequest) =>
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => request(input))
export const request = <const SelectedLanguageModel extends LanguageModel>(
input: RequestInput<SelectedLanguageModel>,
+77
View File
@@ -0,0 +1,77 @@
import { type Context, Effect, Layer, Stream } from "effect"
import { resultEvents, type AwaitOptions, type Generation, type Observation } from "./generation.js"
import { RequestExecutor } from "./route/executor.js"
import type { MediaRoute } from "./route/media.js"
import { AIError, UnsupportedOperationError } from "./schema/index.js"
/** A media request whose model carries the route that executes it. */
export interface RoutedRequest<Self extends MediaRoute.MediaRequest, Event, Response> extends MediaRoute.MediaRequest {
readonly model: MediaRoute.MediaRequest["model"] & { readonly route: MediaRoute.AnyRoute<Self, Event, Response> }
}
/** `start` and `resume` fail with `UnsupportedOperation` on inline and stream routes. */
export interface Interface<Req extends RoutedRequest<Req, Event, Response>, Event, Response> {
readonly generate: (request: Req, options?: AwaitOptions) => Effect.Effect<Response, AIError>
readonly stream: (request: Req, options?: AwaitOptions) => Stream.Stream<Event | Observation, AIError>
readonly start: (request: Req) => Effect.Effect<Generation<Response>, AIError>
readonly resume: (model: Req["model"], token: unknown) => Effect.Effect<Generation<Response>, AIError>
}
/** One modality's layer and service accessors, dispatching each request on its route's `kind`. */
export const make = <Self, Req extends RoutedRequest<Req, Event, Response>, Event, Response>(
service: Context.Service<Self, Interface<Req, Event, Response>>,
input: {
readonly modality: string
/** A completed response expanded into the streaming event shape. */
readonly responseEvents: (response: Response) => ReadonlyArray<Event>
},
) => ({
layer: Layer.effect(
service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const notQueued = (route: MediaRoute.AnyRoute<Req, Event, Response>, operation: string) =>
new AIError({
reason: new UnsupportedOperationError({
operation: `${input.modality}.${operation}`,
provider: route.provider,
route: route.id,
message: `${route.provider}/${route.id} is not a queued route; use generate or stream`,
}),
})
const start = (request: Req) => {
const route = request.model.route
if (route.kind !== "queued") return Effect.fail(notQueued(route, "start"))
return route.start(request, executor.execute)
}
return service.of({
start,
resume: (model, token) => {
if (model.route.kind !== "queued") return Effect.fail(notQueued(model.route, "resume"))
return model.route.resume(model, token, executor.execute)
},
generate: (request, options) => {
const route = request.model.route
if (route.kind !== "queued") return route.generate(request, executor.execute)
return start(request).pipe(Effect.flatMap((generation) => generation.await(options)))
},
stream: (request, options) => {
const route = request.model.route
if (route.kind === "stream") return route.stream(request, executor.execute)
if (route.kind === "queued")
return Stream.unwrap(
start(request).pipe(Effect.map((generation) => resultEvents(generation, input.responseEvents, options))),
)
return Stream.fromIterableEffect(Effect.map(route.generate(request, executor.execute), input.responseEvents))
},
})
}),
),
generate: (request: Req, options?: AwaitOptions) => service.use((client) => client.generate(request, options)),
stream: (request: Req, options?: AwaitOptions) =>
Stream.unwrap(service.useSync((client) => client.stream(request, options))),
start: (request: Req) => service.use((client) => client.start(request)),
resume: (model: Req["model"], token: unknown) => service.use((client) => client.resume(model, token)),
})
export * as MediaClient from "./media-client.js"
+42 -30
View File
@@ -6,11 +6,13 @@ import { AIError, HttpOptions, InvalidRequestError, ModelID, ProviderID } from "
/**
* What every media model carries: ids, the configured route, and deployment `http` overlays. Modality classes
* (`ImageModel`, `VideoModel`, `SpeechModel`) extend it with their route type and a nominal marker so one cannot stand
* in for the other in requests.
* (`ImageModel`, `VideoModel`, `SpeechModel`, `TranscriptionModel`) extend it with their route type and a nominal
* marker so one cannot stand in for the other in requests.
*/
export class MediaModel<Route, Options> {
declare protected readonly _Options: (options: Options) => Options
// As with `LanguageModel`, the route type is erased over `Options`; `fromRoute` and the constructor trust that the
// route accepts every request this model's `Options` admit.
declare protected readonly _Options: Options
readonly id: ModelID
readonly provider: ProviderID
readonly route: Route
@@ -25,6 +27,8 @@ export class MediaModel<Route, Options> {
}
export namespace MediaModel {
export type Options = Record<string, unknown>
export interface Input<Route> {
readonly id: string | ModelID
readonly provider: string | ProviderID
@@ -41,48 +45,56 @@ export namespace MediaModel {
readonly headers?: Record<string, string>
}
export type InlineRouteInput<Request extends MediaRoute.MediaRequest, Response> = RouteInput<
Request,
MediaProtocol.Inline<Request, Response>
>
export type StreamRouteInput<Request extends MediaRoute.MediaRequest, Event, Frame, State> = RouteInput<
MediaProtocol.Addressed<Request>,
MediaProtocol.Streamed<Request, Event, Frame, State>
>
export type QueuedRouteInput<Request extends MediaRoute.MediaRequest, Response, Token> = RouteInput<
Request,
MediaProtocol.Queued<Request, Response, Token>
>
export type AnyRouteInput<Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token> =
| RouteInput<Request, MediaProtocol.Inline<Request, Response>>
| RouteInput<MediaProtocol.Addressed<Request>, MediaProtocol.Streamed<Request, Event, Frame, State>>
| RouteInput<Request, MediaProtocol.Queued<Request, Response, Token>>
| InlineRouteInput<Request, Response>
| StreamRouteInput<Request, Event, Frame, State>
| QueuedRouteInput<Request, Response, Token>
}
/** Compose a protocol route input with one deployment through `MediaRoute.inline`, `queued`, or `stream`. */
export const composeRoute = <Request extends MediaRoute.MediaRequest, Protocol, Route>(
compose: (input: MediaRoute.Composition<Request> & { readonly protocol: Protocol }) => Route,
route: MediaModel.RouteInput<Request, Protocol>,
input: MediaRoute.ModelInput,
): Route =>
compose({
protocol: route.protocol,
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
auth: input.auth,
headers:
route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
})
export const composeAnyRoute = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
export const composeRoute = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
input: MediaRoute.ModelInput,
collect: (events: ReadonlyArray<Event>) => Effect.Effect<Response, AIError>,
): MediaRoute.AnyRoute<Request, Event, Response> => {
if (isStreamInput(route))
return composeRoute((composition) => MediaRoute.stream({ ...composition, collect }), route, input)
if (isQueuedInput(route)) return composeRoute(MediaRoute.queued, route, input)
return composeRoute(MediaRoute.inline, route, input)
if (isStreamInput(route)) return MediaRoute.stream({ ...composition(route, input), collect })
if (isQueuedInput(route)) return MediaRoute.queued(composition(route, input))
return MediaRoute.inline(composition(route, input))
}
const composition = <Request extends MediaRoute.MediaRequest, Protocol>(
route: MediaModel.RouteInput<Request, Protocol>,
input: MediaRoute.ModelInput,
): MediaRoute.Composition<Request> & { readonly protocol: Protocol } => ({
protocol: route.protocol,
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
auth: input.auth,
headers:
route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
})
const isStreamInput = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
): route is MediaModel.RouteInput<
MediaProtocol.Addressed<Request>,
MediaProtocol.Streamed<Request, Event, Frame, State>
> => route.protocol.kind === "stream"
): route is MediaModel.StreamRouteInput<Request, Event, Frame, State> => route.protocol.kind === "stream"
const isQueuedInput = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
): route is MediaModel.RouteInput<Request, MediaProtocol.Queued<Request, Response, Token>> =>
route.protocol.kind === "queued"
): route is MediaModel.QueuedRouteInput<Request, Response, Token> => route.protocol.kind === "queued"
/** Lift a synchronous Schema-class constructor into a typed `InvalidRequest` failure. */
export const tryRequest = <A>(make: () => A): Effect.Effect<A, AIError> =>
+20 -33
View File
@@ -1,23 +1,22 @@
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
import { AIClient } from "./ai-client.js"
import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js"
import { Image, ImageModel, ImageRequest, type ImageOptions, type ImageRequestInput } from "./image.js"
import { Image, type ImageModel, type ImageRequest, type ImageRequestInput } from "./image.js"
import { LLM } from "./index.js"
import { Media } from "./media.js"
import { tryRequest } from "./media-model.js"
import { RequestExecutor } from "./route/executor.js"
import { AIError, InvalidRequestError, LanguageModel, LLMRequest } from "./schema/index.js"
import type { RequestInput } from "./llm.js"
import { Speech, SpeechModel, SpeechRequest, type SpeechRequestInput } from "./speech.js"
import { Speech, type SpeechModel, type SpeechRequest, type SpeechRequestInput } from "./speech.js"
import {
Transcription,
TranscriptionModel,
TranscriptionRequest,
type TranscriptionOptions,
type TranscriptionModel,
type TranscriptionRequest,
type TranscriptionRequestInput,
} from "./transcription.js"
import { fileMediaType } from "./utils/media-type.js"
import { Video, VideoModel, VideoRequest, type VideoOptions, type VideoRequestInput } from "./video.js"
import { Video, type VideoModel, type VideoRequest, type VideoRequestInput } from "./video.js"
/**
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM, image, video, speech,
@@ -93,17 +92,8 @@ export const make = (options: Options = {}) => {
cancel: (options) => run(generation.cancel(), options),
})
// The typed `generate`/`stream` overloads take a concrete input or a request, not the union; normalize once here.
const llmRequest = (input: RequestInput | LLMRequest) =>
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => LLM.request(input))
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
input instanceof ImageRequest ? input : Image.request(input)
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
input instanceof VideoRequest ? input : Video.request(input)
const speechRequest = (input: SpeechRequestInput | SpeechRequest) =>
input instanceof SpeechRequest ? input : Speech.request(input)
const transcriptionRequest = (input: TranscriptionRequestInput | TranscriptionRequest) =>
input instanceof TranscriptionRequest ? input : Transcription.request(input)
return {
run,
@@ -155,61 +145,58 @@ export const make = (options: Options = {}) => {
generate: <const Model extends ImageModel>(
input: ImageRequestInput<Model> | ImageRequest,
options?: AwaitOptions & RunOptions,
) => run(Image.generate(imageRequest(input), { poll: options?.poll }), options),
) => run(Image.generate(input, { poll: options?.poll }), options),
stream: <const Model extends ImageModel>(
input: ImageRequestInput<Model> | ImageRequest,
options?: AwaitOptions & RunOptions,
) => iterate(Image.stream(imageRequest(input), { poll: options?.poll }), options),
) => iterate(Image.stream(input, { poll: options?.poll }), options),
start: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) =>
run(Image.start(imageRequest(input)), options).then(handle),
resume: <Options extends ImageOptions>(model: ImageModel<Options>, token: unknown, options?: RunOptions) =>
run(Image.start(input), options).then(handle),
resume: (model: ImageModel, token: unknown, options?: RunOptions) =>
run(Image.resume(model, token), options).then(handle),
},
video: {
request: Video.request,
start: <const Model extends VideoModel>(input: VideoRequestInput<Model> | VideoRequest, options?: RunOptions) =>
run(Video.start(videoRequest(input)), options).then(handle),
run(Video.start(input), options).then(handle),
generate: <const Model extends VideoModel>(
input: VideoRequestInput<Model> | VideoRequest,
options?: AwaitOptions & RunOptions,
) => run(Video.generate(videoRequest(input), { poll: options?.poll }), options),
resume: <Options extends VideoOptions>(model: VideoModel<Options>, token: unknown, options?: RunOptions) =>
) => run(Video.generate(input, { poll: options?.poll }), options),
resume: (model: VideoModel, token: unknown, options?: RunOptions) =>
run(Video.resume(model, token), options).then(handle),
stream: <const Model extends VideoModel>(
input: VideoRequestInput<Model> | VideoRequest,
options?: AwaitOptions & RunOptions,
) => iterate(Video.stream(videoRequest(input), { poll: options?.poll }), options),
) => iterate(Video.stream(input, { poll: options?.poll }), options),
},
speech: {
request: Speech.request,
generate: <const Model extends SpeechModel>(
input: SpeechRequestInput<Model> | SpeechRequest,
options?: RunOptions,
) => run(Speech.generate(speechRequest(input)), options),
) => run(Speech.generate(input), options),
stream: <const Model extends SpeechModel>(
input: SpeechRequestInput<Model> | SpeechRequest,
options?: RunOptions,
) => iterate(Speech.stream(speechRequest(input)), options),
) => iterate(Speech.stream(input), options),
},
transcription: {
request: Transcription.request,
generate: <const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
options?: AwaitOptions & RunOptions,
) => run(Transcription.generate(transcriptionRequest(input), { poll: options?.poll }), options),
) => run(Transcription.generate(input, { poll: options?.poll }), options),
stream: <const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
options?: AwaitOptions & RunOptions,
) => iterate(Transcription.stream(transcriptionRequest(input), { poll: options?.poll }), options),
) => iterate(Transcription.stream(input, { poll: options?.poll }), options),
start: <const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
options?: RunOptions,
) => run(Transcription.start(transcriptionRequest(input)), options).then(handle),
resume: <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
token: unknown,
options?: RunOptions,
) => run(Transcription.resume(model, token), options).then(handle),
) => run(Transcription.start(input), options).then(handle),
resume: (model: TranscriptionModel, token: unknown, options?: RunOptions) =>
run(Transcription.resume(model, token), options).then(handle),
},
dispose: () => runtime.dispose(),
}
+5 -1
View File
@@ -70,7 +70,11 @@ export const protocol = Protocol.make({
return {
...(yield* OpenAIChat.protocol.body.from(req)),
enable_thinking: opts.enableThinking,
thinking_budget: opts.thinkingBudget,
// Alibaba also rejects an explicit budget that is not below `max_completion_tokens`.
thinking_budget:
opts.thinkingBudget === undefined
? undefined
: ProviderShared.fitThinkingBudget(opts.thinkingBudget, req.generation?.maxTokens),
preserve_thinking: opts.preserveThinking,
clear_thinking: opts.clearThinking,
thinking: opts.thinking,
@@ -26,18 +26,21 @@ export const protocol = Protocol.make({
from: Effect.fn("AlibabaMessages.fromRequest")(function* (req) {
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
// Model Studio accepts enabled thinking without Anthropic's mandatory token budget.
const body = yield* AnthropicMessages.protocol.body.from(
LLMRequest.update(req, {
providerOptions: { ...req.providerOptions, thinking: undefined },
}),
)
const budget = opts.thinking?.budgetTokens ?? opts.thinking?.budget_tokens
return {
...(yield* AnthropicMessages.protocol.body.from(
LLMRequest.update(req, {
providerOptions: { ...req.providerOptions, thinking: undefined },
}),
)),
...body,
thinking:
opts.thinking === undefined
? undefined
: {
type: opts.thinking.type,
budget_tokens: opts.thinking.budgetTokens ?? opts.thinking.budget_tokens,
budget_tokens:
budget === undefined ? undefined : ProviderShared.fitThinkingBudget(budget, body.max_tokens),
},
}
}),
+16 -12
View File
@@ -18,7 +18,6 @@ import {
type CacheHint,
type FinishReasonDetails,
type FinishReason,
type JsonSchema,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
@@ -31,13 +30,13 @@ import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
const MIN_THINKING_BUDGET = 1_024
const DEFAULT_EFFORT = "high"
const SSE_EVENTS = new Set([
@@ -524,10 +523,10 @@ const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: s
return typeof provider.redactedData === "string" ? provider.redactedData : undefined
}
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
name: tool.name,
description: tool.description,
input_schema: inputSchema,
input_schema: tool.inputSchema,
cache_control: cacheControl(breakpoints, tool.cache),
})
@@ -1027,6 +1026,15 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
}
}
// Anthropic also requires an explicit thinking budget below `max_tokens` and at or above its minimum.
const fitThinking = (thinking: AnthropicThinking | undefined, maxTokens: number) =>
thinking?.type === "enabled"
? {
...thinking,
budget_tokens: ProviderShared.fitThinkingBudget(thinking.budget_tokens, maxTokens, MIN_THINKING_BUDGET),
}
: thinking
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
const options = yield* decodeOptions(request.providerOptions ?? {})
const management = options.contextManagement
@@ -1039,12 +1047,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const flattened = ProviderShared.flattenToolRequest(updates.request)
const tools =
flattened.tools.length === 0
? undefined
: flattened.tools.map((tool) =>
lowerTool(breakpoints, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
)
const tools = flattened.tools.length === 0 ? undefined : flattened.tools.map((tool) => lowerTool(breakpoints, tool))
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
const systemParts = request.system.filter((part) => part.text.length > 0)
@@ -1064,6 +1067,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
}
const output_config =
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
const maxTokens = generation?.maxTokens ?? DEFAULT_MAX_TOKENS
const body = {
model: request.model.id,
system,
@@ -1071,12 +1075,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
tools,
tool_choice: toolChoice,
stream: true as const,
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
max_tokens: maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: applyThinkingBindingDefault(request.model, options.thinking),
thinking: applyThinkingBindingDefault(request.model, fitThinking(options.thinking, maxTokens)),
output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control ?? options.cacheControl,
+34 -14
View File
@@ -9,7 +9,6 @@ import {
type CacheHint,
type FinishReason,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type LanguageModel,
type ProviderMetadata,
@@ -26,7 +25,6 @@ import { BedrockCache } from "./utils/bedrock-cache.js"
import { BedrockMedia } from "./utils/bedrock-media.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
import { concatBytes } from "../utils/bytes.js"
@@ -221,22 +219,18 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
// =============================================================================
// Request Lowering
// =============================================================================
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
toolSpec: {
name: tool.name,
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
inputSchema: { json: inputSchema },
inputSchema: { json: tool.inputSchema },
},
})
const lowerTools = (
model: LanguageModel,
breakpoints: BedrockCache.Breakpoints,
tools: ReadonlyArray<ToolDefinition>,
): BedrockTool[] => {
const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
const result: BedrockTool[] = []
for (const tool of tools) {
result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model)))
result.push(lowerToolSpec(tool))
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
if (cachePoint) result.push(cachePoint)
}
@@ -441,19 +435,39 @@ const isHighReasoningEffort = Schema.is(
}),
)
const Options = Schema.Struct({
thinking: Schema.optional(Schema.Struct({ type: Schema.Literal("enabled"), budgetTokens: Schema.Number })),
})
export type OptionsInput = typeof Options.Type
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
// Claude on Bedrock requires the thinking budget below `maxTokens`, with a minimum of 1,024.
const MIN_THINKING_BUDGET = 1_024
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
const flattened = ProviderShared.flattenToolRequest(request)
const generation = request.generation
const options = yield* decodeOptions(request.providerOptions ?? {})
const maxTokens =
isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens
const thinking =
options.thinking === undefined
? undefined
: {
type: "enabled",
budget_tokens: ProviderShared.fitThinkingBudget(
options.thinking.budgetTokens,
maxTokens,
MIN_THINKING_BUDGET,
),
}
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
// tools → system → messages order to favour the highest-impact prefixes.
const breakpoints = BedrockCache.breakpoints(request.model.id)
const toolConfig = (() => {
if (flattened.tools.length === 0) return undefined
return {
tools: lowerTools(request.model, breakpoints, flattened.tools),
tools: lowerTools(breakpoints, flattened.tools),
// Converse has no native "none". Keep definitions stable for prompt
// caching and omit only the unsupported choice.
toolChoice,
@@ -487,9 +501,15 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
system,
inferenceConfig,
toolConfig,
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
// as a model-specific field, so it goes through additionalModelRequestFields.
additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK },
// Converse's base inferenceConfig has no topK or thinking; Anthropic/Nova accept them
// as model-specific fields, so they go through additionalModelRequestFields.
additionalModelRequestFields:
generation?.topK === undefined && thinking === undefined
? undefined
: {
...(generation?.topK === undefined ? {} : { top_k: generation.topK }),
...(thinking === undefined ? {} : { thinking }),
},
}
})
+21 -8
View File
@@ -11,7 +11,6 @@ import {
Usage,
type FinishReason,
type LLMRequest,
type LanguageModel,
type MediaPart,
type ProviderMetadata,
type ProviderOptions,
@@ -24,11 +23,12 @@ import { Media } from "../media.js"
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
const ADAPTER = "gemini"
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
// Gemini 2.5 rejects a budget under the model's minimum: 512 on Flash-Lite, the highest, and 128 on Pro.
const MIN_THINKING_BUDGET = 512
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
@@ -268,12 +268,11 @@ interface ParserState {
// =============================================================================
// Request Lowering
// =============================================================================
// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema. Gemini's schema
// rules are this API's default, including for tuned endpoints whose IDs do not name Gemini.
const lowerTool = (tool: ToolDefinition, model: LanguageModel) => ({
// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema.
const lowerTool = (tool: ToolDefinition) => ({
name: tool.name,
description: tool.description,
parametersJsonSchema: ToolSchemaProjection.modelCompatibility(tool.inputSchema, model, "gemini"),
parametersJsonSchema: tool.inputSchema,
})
const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -452,10 +451,22 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
presencePenalty: generation?.presencePenalty,
seed: generation?.seed,
stopSequences: generation?.stop,
// Gemini accepts a budget above `maxOutputTokens`, but thinking then leaves the answer empty.
thinkingConfig:
options.thinkingConfig === undefined
? undefined
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
: {
...options.thinkingConfig,
includeThoughts: options.thinkingConfig.includeThoughts ?? true,
thinkingBudget:
options.thinkingConfig.thinkingBudget === undefined
? undefined
: ProviderShared.fitThinkingBudget(
options.thinkingConfig.thinkingBudget,
generation?.maxTokens,
MIN_THINKING_BUDGET,
),
},
}
return {
@@ -468,7 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
tools: hasTools
? [
{
functionDeclarations: flattened.tools.map((tool) => lowerTool(tool, request.model)),
functionDeclarations: flattened.tools.map(lowerTool),
},
]
: undefined,
@@ -804,6 +815,8 @@ export const protocol = Protocol.make({
schema: GeminiBody,
from: fromRequest,
},
// Gemini's schema rules are this API's default, including for tuned endpoints whose IDs do not name Gemini.
sanitizer: "gemini",
stream: {
event: Protocol.jsonEvent(GeminiEvent),
initial: (request) => ({
+1 -7
View File
@@ -5,7 +5,6 @@ import { LLMEvent, LLMRequest, Message, ToolResultPart } from "../schema/index.j
import { OpenResponses } from "./open-responses.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { detectMediaType } from "../utils/media-type.js"
const ADAPTER = "meta-responses"
@@ -103,12 +102,7 @@ const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: L
? undefined
: yield* Effect.forEach(projected.tools, (tool) =>
Effect.gen(function* () {
if (tool.native === undefined)
return yield* OpenResponses.lowerTool(
NAME,
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model),
)
if (tool.native === undefined) return yield* OpenResponses.lowerTool(NAME, tool)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta)
}),
),
+3 -10
View File
@@ -13,7 +13,6 @@ import {
UnknownProviderError,
Usage,
type FinishReasonDetails,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ToolCallPart,
@@ -23,7 +22,6 @@ import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { MistralToolID } from "./utils/mistral-tool-id.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "mistral-chat"
@@ -368,9 +366,9 @@ const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request:
return messages
})
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): MistralTool => ({
const lowerTool = (tool: ToolDefinition): MistralTool => ({
type: "function",
function: { name: tool.name, description: tool.description, parameters: inputSchema, strict: false },
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false },
})
export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) {
@@ -396,12 +394,7 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
return {
model: request.model.id,
messages: yield* lowerMessages(flattened.request),
tools:
flattened.tools.length > 0
? flattened.tools.map((tool) =>
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
)
: undefined,
tools: flattened.tools.length > 0 ? flattened.tools.map(lowerTool) : undefined,
tool_choice: toolChoice,
stream: true as const,
max_tokens: request.generation?.maxTokens,
+8 -16
View File
@@ -8,7 +8,6 @@ import {
ProviderInternalError,
Usage,
type FinishReason,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
@@ -24,7 +23,6 @@ import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate } from "../effort-updates.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "open-responses"
@@ -443,23 +441,24 @@ interface ReasoningStreamItem {
// =============================================================================
// Request Lowering
// =============================================================================
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
protocolName: string,
tool: ToolDefinition,
inputSchema: JsonSchema,
) {
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protocolName: string, tool: ToolDefinition) {
if (tool.native !== undefined)
return yield* ProviderShared.invalidRequest(`${protocolName} does not support provider-native tool ${tool.name}`)
return {
type: "function" as const,
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: tool.inputSchema,
// The common tool definition does not currently express Responses strict-schema policy.
strict: false,
}
})
export const lowerTools = (tools: ReadonlyArray<ToolDefinition>, adapter: ProviderAdapter) =>
Effect.forEach(tools, (tool) =>
tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) : lowerTool(adapter.name, tool),
)
export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
ProviderShared.matchToolChoice(protocolName, toolChoice, {
auto: () => "auto" as const,
@@ -821,14 +820,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
return {
...(yield* lowerConversation(projected.request, adapter)),
...lowerGeneration(request),
tools:
projected.tools.length === 0
? undefined
: yield* Effect.forEach(projected.tools, (tool) =>
tool.native !== undefined && adapter.nativeTool
? adapter.nativeTool(tool.native)
: lowerTool(adapter.name, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
),
tools: projected.tools.length === 0 ? undefined : yield* lowerTools(projected.tools, adapter),
tool_choice:
allowedToolChoice(request) ??
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
+3 -17
View File
@@ -17,7 +17,6 @@ import {
type FinishReason,
type FinishReasonDetails,
type CacheHint,
type JsonSchema,
type LLMRequest,
type MediaPart,
type ReasoningPart,
@@ -29,7 +28,6 @@ import { classifyProviderFailure } from "../provider-error.js"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIOptions } from "./utils/openai-options.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { ToolStream } from "./utils/tool-stream.js"
const ADAPTER = "openai-chat"
@@ -330,17 +328,12 @@ interface LoweringOptions {
readonly toolCallID?: (id: string) => string
}
const lowerTool = (
tool: ToolDefinition,
inputSchema: JsonSchema,
options: LoweringOptions,
supportsStrictMode: boolean,
): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, options: LoweringOptions, supportsStrictMode: boolean): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: inputSchema,
parameters: tool.inputSchema,
...(supportsStrictMode ? { strict: false } : {}),
},
cache_control: options.cacheControl?.(tool.cache),
@@ -825,14 +818,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
? hasHistory
? []
: undefined
: flattened.tools.map((tool) =>
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model),
options,
supportsStrictMode,
),
),
: flattened.tools.map((tool) => lowerTool(tool, options, supportsStrictMode)),
tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
+20 -38
View File
@@ -5,20 +5,12 @@ import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import {
LLMRequest,
mergeJsonRecords,
type JsonSchema,
type LanguageModel,
type ToolDefinition,
type ToolEntry,
} from "../schema/index.js"
import { LLMRequest, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { resolveEffortUpdates } from "../effort-updates.js"
import { OpenResponses } from "./open-responses.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { ResponsesCompaction } from "./utils/responses-compaction.js"
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
@@ -143,11 +135,6 @@ export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compactio
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
),
})
const adapter = {
@@ -174,20 +161,19 @@ const nativeImageTool = (tool: ToolDefinition) => {
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
}
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) {
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition) {
const native = nativeImageToolInput(tool)
if (native !== undefined) {
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
return yield* ProviderShared.invalidRequest("OpenAI Responses image generation tool options are invalid")
}
return yield* OpenResponses.lowerTool(NAME, tool, inputSchema)
return yield* OpenResponses.lowerTool(NAME, tool)
})
// Native namespaces hold only function tools, so deeper levels flatten into
// the leaf names the same way non-native protocols flatten the whole tree.
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry, model: LanguageModel) {
if (tool.type === "tool")
return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model))
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry) {
if (tool.type === "tool") return yield* lowerTool(tool)
// OpenAI requires a namespace description; fall back to a generic one so a
// missing description never blocks the request.
return {
@@ -195,11 +181,13 @@ const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (to
name: tool.name,
description: tool.description ?? `Tools in the ${tool.name} namespace.`,
tools: yield* Effect.forEach(ProviderShared.flattenTools(tool.tools), (leaf) =>
OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, model)),
OpenResponses.lowerTool(NAME, leaf),
),
}
})
const lowerTools = (request: LLMRequest) => Effect.forEach(request.tools, lowerToolEntry)
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolEntry>) =>
ProviderShared.matchToolChoice(NAME, toolChoice, {
auto: () => "auto" as const,
@@ -223,10 +211,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
tools:
request.tools.length === 0
? undefined
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, request.model)),
tools: request.tools.length === 0 ? undefined : yield* lowerTools(request),
tool_choice:
request.tools.length === 0
? undefined
@@ -238,7 +223,6 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
const checkpointBody = {
schema: CheckpointBody,
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
const overlay = request.http?.body
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
if (
@@ -249,18 +233,13 @@ const checkpointBody = {
return yield* ProviderShared.invalidRequest(
"Trigger compaction requires complete canonical history, not an input or continuation override",
)
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
...mergeJsonRecords(native, overlay),
input: [...native.input, { type: "compaction_trigger" }],
stream: true,
store: false,
parallel_tool_calls: true,
tool_choice: undefined,
context_management: undefined,
text: undefined,
max_output_tokens: undefined,
max_tool_calls: undefined,
})
if (overlay?.stream !== undefined && overlay.stream !== true)
return yield* ProviderShared.invalidRequest("Trigger compaction requires a streamed response")
const native = yield* fromRequest(request)
return {
...native,
input: [...native.input, { type: "compaction_trigger" as const }],
}
}),
}
@@ -342,7 +321,10 @@ export const transport = channelTransport({
})
export const route = Route.make({
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
compact: {
endpoint: ResponsesCompaction.make(adapter, lowerTools),
trigger: ResponsesCheckpoint.make(checkpointBody),
},
id: ADAPTER,
provider: "openai",
providerMetadataKey: "openai",
+8
View File
@@ -110,6 +110,14 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
}
/**
* Caps an explicit thinking budget at half the output limit. Thinking counts against the output limit, so a budget
* near it leaves the answer, a tool call, or a summary without room. Smaller budgets, special values such as `-1` and
* `0`, and requests without an output limit pass through unchanged.
*/
export const fitThinkingBudget = (budget: number, maxTokens: number | undefined, minimum = 1) =>
maxTokens === undefined || budget <= maxTokens / 2 ? budget : Math.max(minimum, Math.floor(maxTokens / 2))
export const eventError = (route: string, message: string, body?: string, cause?: unknown) =>
new AIError({
reason: new InvalidProviderOutputError({ route, message, body, cause }),
@@ -1,7 +1,7 @@
import { Effect, Schema, Stream } from "effect"
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
import { Protocol } from "../../route/protocol.js"
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
import { CompactionCheckpointResponse, LLMEvent, LLMRequest } from "../../schema/index.js"
import { OpenResponses } from "../open-responses.js"
import { ProviderShared } from "../shared.js"
@@ -109,12 +109,8 @@ export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
transport: source.transport,
})
const native = yield* body.from(request)
// The body builder already applied and validated overlays. Do not let transport reapply them.
const preparedRequest = LLMRequest.update(request, {
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
})
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
const prepared = yield* route.prepareTransport(native, request, options)
yield* route.streamPrepared(prepared, request, { http: executor }, options).pipe(Stream.runDrain)
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
return result
})
@@ -19,12 +19,18 @@ import { OpenResponses } from "../open-responses.js"
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
import { Media } from "../../media.js"
// /compact has a smaller wire contract than /responses; keep the request controls it accepts.
const Body = Schema.Struct({
model: Schema.String,
input: Schema.Array(Schema.Unknown),
instructions: optionalNull(Schema.String),
previous_response_id: optionalNull(Schema.String),
service_tier: optionalNull(Schema.String),
reasoning: Schema.optional(JsonObject),
text: Schema.optional(JsonObject),
include: OpenResponses.coreFields.include,
parallel_tool_calls: OpenResponses.coreFields.parallel_tool_calls,
tools: Schema.optional(Schema.Array(JsonObject)),
prompt_cache_key: optionalNull(Schema.String),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
@@ -74,17 +80,27 @@ const Response = Schema.Struct({
usage: Schema.optional(Schema.StructWithRest(OpenResponses.OpenResponsesUsage, [JsonObject])),
})
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
export const make = (
adapter: OpenResponses.ProviderAdapter,
lowerTools: (request: LLMRequest) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, AIError>,
): CompactOperation =>
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
const route = request.model.route
// The standalone compaction endpoint rejects histories containing configuration updates.
const native = yield* OpenResponses.lowerConversation(stripEffortUpdates(request), adapter)
const generation = OpenResponses.lowerGeneration(request)
const tools = request.tools.length === 0 ? undefined : yield* lowerTools(request)
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
mergeJsonRecords(
{
...native,
service_tier: request.providerOptions?.serviceTier,
prompt_cache_key: ProviderShared.promptCacheKey(request),
service_tier: generation.service_tier,
reasoning: generation.reasoning,
text: generation.text,
include: generation.include,
parallel_tool_calls: generation.parallel_tool_calls,
tools,
prompt_cache_key: generation.prompt_cache_key,
},
request.http?.body,
),
+16 -8
View File
@@ -1,4 +1,4 @@
import type { JsonSchema, LanguageModel, LanguageModelSanitizerCompatibility } from "../../schema/index.js"
import { ToolDefinition, type JsonSchema, type LanguageModel, type LLMRequest } from "../../schema/index.js"
import { isRecord } from "../../utils/record.js"
import { GeminiJsonSchema } from "./gemini-json-schema.js"
@@ -70,13 +70,13 @@ const objectRoot = (schema: JsonSchema): JsonSchema => {
// Otherwise the protocol's own default applies (the Gemini API always uses Gemini's rules), then the
// model name selects the family's rules so models reached through gateways and OpenAI-compatible
// endpoints get the same handling.
const modelCompatibility = (
schema: JsonSchema,
model: LanguageModel,
protocolDefault?: LanguageModelSanitizerCompatibility,
): JsonSchema => {
const modelCompatibility = (schema: JsonSchema, model: LanguageModel): JsonSchema => {
const root = objectRoot(schema)
switch (model.compatibility?.sanitizer ?? protocolDefault ?? MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]) {
switch (
model.compatibility?.sanitizer ??
model.route.sanitizer ??
MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]
) {
case "gemini":
return gemini(root)
case "moonshot":
@@ -87,10 +87,18 @@ const modelCompatibility = (
}
}
// Applied once to every request before any protocol builds its body, including tools in namespaces.
const tools = (entries: LLMRequest["tools"], model: LanguageModel): LLMRequest["tools"] =>
entries.map((tool) =>
tool.type === "tool"
? new ToolDefinition({ ...tool, inputSchema: modelCompatibility(tool.inputSchema, model) })
: { ...tool, tools: tools(tool.tools, model) },
)
export const ToolSchemaProjection = {
gemini,
modelCompatibility,
moonshot,
openAI,
responses,
tools,
} as const
+5 -2
View File
@@ -50,7 +50,8 @@ const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LL
operation: "in-band-compaction",
provider: request.model.provider,
route: request.model.route.id,
message: "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
message:
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
})
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
})
@@ -93,6 +94,8 @@ export const protocol = Protocol.make({
},
})
export const compact = ResponsesCompaction.make(adapter)
export const compact = ResponsesCompaction.make(adapter, (request) =>
OpenResponses.lowerTools(ProviderShared.flattenTools(request.tools), adapter),
)
export * as XAIResponses from "./xai-responses.js"
+7 -1
View File
@@ -80,7 +80,13 @@ const SERVER_CODES = new Set([
"slow_down",
"serviceunavailableexception",
])
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
// `invalid_request` is the Vercel AI Gateway's code for an upstream request rejection.
const INVALID_REQUEST_CODES = new Set([
"invalid_prompt",
"invalid_request",
"invalid_request_error",
"validationexception",
])
// Azure OpenAI reports `content_filter` with `innererror.code` ResponsibleAIPolicyViolation.
// OpenRouter tags provider failures with a typed `error_type`; its Responses skin also
// emits `image_content_policy_violation` as the native code.
@@ -31,6 +31,7 @@ export interface Settings extends ProviderPackage.Settings {
readonly profile?: string
readonly region?: string
readonly topP?: number
readonly thinking?: BedrockConverse.OptionsInput["thinking"]
}
export const routes = [BedrockConverse.route]
@@ -71,6 +72,7 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.thinking === undefined ? undefined : { thinking: settings.thinking },
profile: settings.profile,
region: settings.region,
}).model(modelID)
+4 -15
View File
@@ -32,10 +32,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
return result
}
export const gpt5DefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined => {
export const gpt5DefaultOptions = (modelID: string): ProviderOptions | undefined => {
const id = modelID.toLowerCase()
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
return openAIProviderOptions({
@@ -47,27 +44,19 @@ export const gpt5DefaultOptions = (
// this, callers using the default model facade get reasoning summaries
// they cannot replay statelessly.
include: ["reasoning.encrypted_content"],
textVerbosity:
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
? "low"
: undefined,
})
}
export const openAIDefaultOptions = (
modelID: string,
options: { readonly textVerbosity?: boolean } = {},
): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
export const openAIDefaultOptions = (modelID: string): ProviderOptions | undefined =>
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID))
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
modelID: string,
options: Options,
defaults: { readonly textVerbosity?: boolean } = {},
): Omit<Options, "providerOptions"> & { readonly providerOptions?: ProviderOptions } => {
return {
...options,
providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID, defaults), options.providerOptions),
providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID), options.providerOptions),
}
}
+1 -1
View File
@@ -100,7 +100,7 @@ export const configure = (input: Config = {}) => {
const modelDefaults = defaults(input)
const responses = (id: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.with(withOpenAIOptions(id, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({
+11 -4
View File
@@ -8,7 +8,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { SystemOne } from "../experimental/system-one.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord } from "../protocols/shared.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
export const id = ProviderID.make("openrouter")
const baseURL = "https://openrouter.ai/api/v1"
@@ -123,7 +123,7 @@ export const protocol = Protocol.make({
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...bodyOptions(request.providerOptions, request.generation?.maxTokens),
} as OpenRouterBody
}),
),
@@ -143,7 +143,14 @@ const cacheControl = () => {
}
}
const bodyOptions = (input: unknown) => {
// OpenRouter forwards `reasoning.max_tokens` as the upstream thinking budget. Upstreams such as Anthropic and Alibaba
// reject one that is not below the output limit; 1,024 is Anthropic's minimum budget.
const fitReasoning = (reasoning: Record<string, unknown>, maxTokens: number | undefined) =>
typeof reasoning.max_tokens === "number"
? { ...reasoning, max_tokens: ProviderShared.fitThinkingBudget(reasoning.max_tokens, maxTokens, 1_024) }
: reasoning
const bodyOptions = (input: unknown, maxTokens: number | undefined) => {
const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
@@ -162,7 +169,7 @@ const bodyOptions = (input: unknown) => {
...(isRecord(web_search_options) ? { web_search_options } : {}),
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(isRecord(reasoning) ? { reasoning: fitReasoning(reasoning, maxTokens) } : {}),
}
}
+7 -2
View File
@@ -11,7 +11,8 @@ import { applyEffortUpdates } from "../effort-updates.js"
import { normalizeToolHistory } from "../tool-history.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import { ToolSchemaProjection } from "../protocols/utils/tool-schema.js"
import type { LanguageModelSanitizerCompatibility, ProtocolID, ProviderOptions } from "../schema/index.js"
import {
AIError,
CompactionResponse,
@@ -57,6 +58,7 @@ export interface Route<
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
readonly sanitizer?: LanguageModelSanitizerCompatibility
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
@@ -388,6 +390,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
defaults: routeInput.defaults ?? {},
body: protocol.body,
supportsEffortUpdates: protocol.supportsEffortUpdates,
sanitizer: protocol.sanitizer,
with: (patch: RoutePatch<Body, Prepared>) => {
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
@@ -559,7 +562,9 @@ const prepareRequest = (request: LLMRequest) => {
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
)
const resolved = applyCachePolicy(
applyEffortUpdates(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })),
applyEffortUpdates(
LLMRequest.update(sanitized, { tools: ToolSchemaProjection.tools(dedupe(sanitized.tools), sanitized.model) }),
),
)
const headers = resolved.model.route.headers?.({ request: resolved })
return headers === undefined
+4 -57
View File
@@ -5,7 +5,7 @@ import { Endpoint } from "./endpoint.js"
import { RequestExecutorService, type Interface } from "./executor-service.js"
import { RequestExecutor } from "./executor.js"
import { MediaProtocol } from "./media-protocol.js"
import { Generation, resultEvents, type AwaitOptions, type Observation } from "../generation.js"
import { Generation } from "../generation.js"
import type { Media } from "../media.js"
import {
AIError,
@@ -52,7 +52,7 @@ export const deployment = (
// ---------------------------------------------------------------------------
/** One request, one response. */
export interface Route<Request extends MediaRequest, Response> {
export interface InlineRoute<Request extends MediaRequest, Response> {
readonly kind: "inline"
readonly id: string
readonly provider: ProviderID
@@ -86,7 +86,7 @@ export interface StreamRoute<Request extends MediaRequest, Event, Response> {
}
export type AnyRoute<Request extends MediaRequest, Event, Response> =
| Route<Request, Response>
| InlineRoute<Request, Response>
| StreamRoute<Request, Event, Response>
| QueuedRoute<Request, Response>
@@ -119,7 +119,7 @@ export interface StreamInput<Request extends MediaRequest, Event, Response, Fram
*/
export const inline = <Request extends MediaRequest, Response>(
input: InlineInput<Request, Response>,
): Route<Request, Response> => {
): InlineRoute<Request, Response> => {
const transport = makeTransport(input)
return {
kind: "inline",
@@ -267,59 +267,6 @@ export const stream = <Request extends MediaRequest, Event, Response, Frame, Sta
}
}
export const dispatch = <Event, Response>(input: {
readonly modality: string
readonly execute: Execute
readonly responseEvents: (response: Response) => ReadonlyArray<Event>
}) => {
const notQueued = (route: { readonly provider: ProviderID; readonly id: string }, operation: string) =>
new AIError({
reason: new UnsupportedOperationError({
operation: `${input.modality}.${operation}`,
provider: route.provider,
route: route.id,
message: `${route.provider}/${route.id} is not a queued route; use generate or stream`,
}),
})
const start = <Request extends MediaRequest>(route: AnyRoute<Request, Event, Response>, request: Request) => {
if (route.kind !== "queued") return Effect.fail(notQueued(route, "start"))
return route.start(request, input.execute)
}
return {
start,
resume: <Request extends MediaRequest>(
route: AnyRoute<Request, Event, Response>,
model: MediaRequest["model"],
token: unknown,
) => {
if (route.kind !== "queued") return Effect.fail(notQueued(route, "resume"))
return route.resume(model, token, input.execute)
},
generate: <Request extends MediaRequest>(
route: AnyRoute<Request, Event, Response>,
request: Request,
options?: AwaitOptions,
) => {
if (route.kind !== "queued") return route.generate(request, input.execute)
return start(route, request).pipe(Effect.flatMap((generation) => generation.await(options)))
},
stream: <Request extends MediaRequest>(
route: AnyRoute<Request, Event, Response>,
request: Request,
options?: AwaitOptions,
): Stream.Stream<Event | Observation, AIError> => {
if (route.kind === "stream") return route.stream(request, input.execute)
if (route.kind === "queued")
return Stream.unwrap(
start(route, request).pipe(
Effect.map((generation) => resultEvents(generation, input.responseEvents, options)),
),
)
return Stream.fromIterableEffect(Effect.map(route.generate(request, input.execute), input.responseEvents))
},
}
}
// ---------------------------------------------------------------------------
// Transport plumbing shared by every kind
// ---------------------------------------------------------------------------
+3 -1
View File
@@ -1,5 +1,5 @@
import { Schema, type Effect } from "effect"
import type { AIError, LLMEvent, LLMRequest, ProtocolID } from "../schema/index.js"
import type { AIError, LanguageModelSanitizerCompatibility, LLMEvent, LLMRequest, ProtocolID } from "../schema/index.js"
/**
* The semantic API contract of one model server family.
@@ -43,6 +43,8 @@ export interface Protocol<Body, Frame, Event, State> {
readonly stream: ProtocolStream<Frame, Event, State>
/** Whether `body.from` lowers `Message.effort(...)` markers; wrappers around another `body.from` must forward it. */
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
/** Tool schema sanitizer for every model on this protocol unless the model's compatibility sets one; wrappers around another `body.from` must forward it. */
readonly sanitizer?: LanguageModelSanitizerCompatibility
}
export interface ProtocolBody<Body> {
+22 -44
View File
@@ -1,53 +1,31 @@
import { Context, Effect, Layer, Stream } from "effect"
import { RequestExecutor } from "./route/executor.js"
import type { AIError } from "./schema/index.js"
import type { SpeechEvent, SpeechOptions, SpeechRequestFor, SpeechResponse } from "./speech.js"
import { Context } from "effect"
import { MediaClient } from "./media-client.js"
import {
SpeechTimestampsEvent,
SpeechFinishEvent,
type SpeechEvent,
type SpeechRequestFor,
type SpeechResponse,
} from "./speech.js"
export interface Interface {
readonly generate: <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
) => Effect.Effect<SpeechResponse, AIError>
readonly stream: <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
) => Stream.Stream<SpeechEvent, AIError>
}
export type Interface = MediaClient.Interface<SpeechRequestFor, SpeechEvent, SpeechResponse>
export class SpeechClientService extends Context.Service<SpeechClientService, Interface>()("@opencode/SpeechClient") {}
export const Service = SpeechClientService
export type Service = SpeechClientService
export const generate = <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
): Effect.Effect<SpeechResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request)
})
export const stream = <Options extends SpeechOptions>(
request: SpeechRequestFor<Options>,
): Stream.Stream<SpeechEvent, AIError, Service> =>
Stream.unwrap(
Effect.gen(function* () {
const client = yield* Service
return client.stream(request)
}),
)
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) => request.model.route.generate(request, executor.execute),
stream: (request) => request.model.route.stream(request, executor.execute),
})
}),
)
export const SpeechClient = {
Service,
layer,
generate,
stream,
...MediaClient.make(Service, {
modality: "speech",
responseEvents: (response: SpeechResponse) => [
...(response.timestamps === undefined ? [] : [SpeechTimestampsEvent.make({ items: response.timestamps })]),
SpeechFinishEvent.make({
audio: response.audio,
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
],
}),
} as const
+31 -41
View File
@@ -1,8 +1,8 @@
import { Effect, Schema, Stream } from "effect"
import { ProgressEvent, QueuedEvent } from "./generation.js"
import { Media } from "./media.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
import { SpeechClient, Service } from "./speech-client.js"
@@ -10,53 +10,39 @@ import { SpeechClient, Service } from "./speech-client.js"
// Model
// ---------------------------------------------------------------------------
export type SpeechOptions = Record<string, unknown>
export type SpeechOptions = MediaModel.Options
export type SpeechRoute<Options extends SpeechOptions = SpeechOptions> = MediaRoute.StreamRoute<
SpeechRequestFor<Options>,
SpeechEvent,
SpeechResponse
>
export type SpeechRoute = MediaRoute.AnyRoute<SpeechRequestFor, SpeechEvent, SpeechResponse>
export class SpeechModel<Options extends SpeechOptions = SpeechOptions> extends MediaModel<
SpeechRoute<Options>,
Options
> {
export class SpeechModel<Options extends SpeechOptions = SpeechOptions> extends MediaModel<SpeechRoute, Options> {
declare protected readonly _SpeechModel: void
static make<Options extends SpeechOptions = SpeechOptions>(input: MediaModel.Input<SpeechRoute<Options>>) {
return new SpeechModel<Options>(input)
}
/** Compose a streaming speech protocol with its canonical path into a model for one deployment. */
static fromRoute<Options extends SpeechOptions = SpeechOptions, Frame = unknown, State = unknown>(
route: SpeechModel.RouteInput<Options, Frame, State>,
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
static fromRoute<Options extends SpeechOptions>(
route: MediaModel.InlineRouteInput<SpeechRequestFor<Options>, SpeechResponse>,
input: MediaRoute.ModelInput,
): SpeechModel<Options>
static fromRoute<Options extends SpeechOptions, Frame, State>(
route: MediaModel.StreamRouteInput<SpeechRequestFor<Options>, SpeechEvent, Frame, State>,
input: MediaRoute.ModelInput,
): SpeechModel<Options>
static fromRoute<Options extends SpeechOptions, Token>(
route: MediaModel.QueuedRouteInput<SpeechRequestFor<Options>, SpeechResponse, Token>,
input: MediaRoute.ModelInput,
): SpeechModel<Options>
static fromRoute<Options extends SpeechOptions, Frame, State, Token>(
route: MediaModel.AnyRouteInput<SpeechRequestFor<Options>, SpeechEvent, SpeechResponse, Frame, State, Token>,
input: MediaRoute.ModelInput,
) {
return new SpeechModel<Options>({
id: input.id,
provider: route.protocol.provider,
http: input.http,
route: composeRoute(
(composition) => MediaRoute.stream({ ...composition, collect: collectResponse }),
route,
input,
),
route: composeRoute(route, input, collectResponse) as SpeechRoute,
})
}
}
export namespace SpeechModel {
export type RouteInput<
Options extends SpeechOptions = SpeechOptions,
Frame = unknown,
State = unknown,
> = MediaModel.RouteInput<
MediaProtocol.Addressed<SpeechRequestFor<Options>>,
MediaProtocol.Streamed<SpeechRequestFor<Options>, SpeechEvent, Frame, State>
>
}
export const SpeechModelSchema = Schema.declare((value): value is SpeechModel => value instanceof SpeechModel, {
expected: "Speech.Model",
})
@@ -148,11 +134,17 @@ export const SpeechFinishEvent = Schema.Struct({
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "Speech.Event.Finish" })
const speechEventTagged = Schema.Union([SpeechAudioDeltaEvent, SpeechTimestampsEvent, SpeechFinishEvent]).pipe(
Schema.toTaggedUnion("type"),
)
const speechEventTagged = Schema.Union([
QueuedEvent,
ProgressEvent,
SpeechAudioDeltaEvent,
SpeechTimestampsEvent,
SpeechFinishEvent,
]).pipe(Schema.toTaggedUnion("type"))
export const SpeechEvent = Object.assign(speechEventTagged, {
is: {
generationQueued: speechEventTagged.guards["generation-queued"],
generationProgress: speechEventTagged.guards["generation-progress"],
audioDelta: speechEventTagged.guards["audio-delta"],
timestamps: speechEventTagged.guards.timestamps,
finish: speechEventTagged.guards.finish,
@@ -195,17 +187,15 @@ export function request(input: SpeechRequest | SpeechRequestInput) {
const requestEffect = (input: SpeechRequest | SpeechRequestInput) => tryRequest(() => request(input))
export function generate<const Model extends SpeechModel>(
input: SpeechRequestInput<Model>,
input: SpeechRequest | SpeechRequestInput<Model>,
): Effect.Effect<SpeechResponse, AIError, Service>
export function generate(input: SpeechRequest): Effect.Effect<SpeechResponse, AIError, Service>
export function generate(input: SpeechRequest | SpeechRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => SpeechClient.generate(request)))
}
export function stream<const Model extends SpeechModel>(
input: SpeechRequestInput<Model>,
input: SpeechRequest | SpeechRequestInput<Model>,
): Stream.Stream<SpeechEvent, AIError, Service>
export function stream(input: SpeechRequest): Stream.Stream<SpeechEvent, AIError, Service>
export function stream(input: SpeechRequest | SpeechRequestInput) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => SpeechClient.stream(request))))
}
+8 -85
View File
@@ -1,34 +1,13 @@
import { Context, Effect, Layer, Stream } from "effect"
import type { AwaitOptions, Generation } from "./generation.js"
import { RequestExecutor } from "./route/executor.js"
import { MediaRoute } from "./route/media.js"
import type { AIError } from "./schema/index.js"
import { Context } from "effect"
import { MediaClient } from "./media-client.js"
import {
responseEvents,
TranscriptionFinishEvent,
type TranscriptionEvent,
type TranscriptionModel,
type TranscriptionOptions,
type TranscriptionRequestFor,
type TranscriptionResponse,
} from "./transcription.js"
export interface Interface {
readonly generate: <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
options?: AwaitOptions,
) => Effect.Effect<TranscriptionResponse, AIError>
readonly stream: <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
options?: AwaitOptions,
) => Stream.Stream<TranscriptionEvent, AIError>
readonly start: <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
readonly resume: <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
token: unknown,
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
}
export type Interface = MediaClient.Interface<TranscriptionRequestFor, TranscriptionEvent, TranscriptionResponse>
export class TranscriptionClientService extends Context.Service<TranscriptionClientService, Interface>()(
"@opencode/TranscriptionClient",
@@ -36,66 +15,10 @@ export class TranscriptionClientService extends Context.Service<TranscriptionCli
export const Service = TranscriptionClientService
export type Service = TranscriptionClientService
export const generate = <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
options?: AwaitOptions,
): Effect.Effect<TranscriptionResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request, options)
})
export const stream = <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
options?: AwaitOptions,
): Stream.Stream<TranscriptionEvent, AIError, Service> =>
Stream.unwrap(
Effect.gen(function* () {
const client = yield* Service
return client.stream(request, options)
}),
)
export const start = <Options extends TranscriptionOptions>(
request: TranscriptionRequestFor<Options>,
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.start(request)
})
export const resume = <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
token: unknown,
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.resume(model, token)
})
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const dispatch = MediaRoute.dispatch<TranscriptionEvent, TranscriptionResponse>({
modality: "transcription",
execute: executor.execute,
responseEvents,
})
return Service.of({
start: (request) => dispatch.start(request.model.route, request),
resume: (model, token) => dispatch.resume(model.route, model, token),
generate: (request, options) => dispatch.generate(request.model.route, request, options),
stream: (request, options) => dispatch.stream(request.model.route, request, options),
})
}),
)
export const TranscriptionClient = {
Service,
layer,
generate,
stream,
start,
resume,
...MediaClient.make(Service, {
modality: "transcription",
responseEvents: (response: TranscriptionResponse) => [TranscriptionFinishEvent.make({ ...response })],
}),
} as const
+21 -76
View File
@@ -1,9 +1,8 @@
import { Effect, Schema, Stream } from "effect"
import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js"
import { Media } from "./media.js"
import { MediaModel, composeAnyRoute, tryRequest } from "./media-model.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata } from "./schema/index.js"
import { TranscriptionClient, Service } from "./transcription-client.js"
@@ -11,90 +10,49 @@ import { TranscriptionClient, Service } from "./transcription-client.js"
// Model
// ---------------------------------------------------------------------------
export type TranscriptionOptions = Record<string, unknown>
export type TranscriptionOptions = MediaModel.Options
export type TranscriptionRoute<Options extends TranscriptionOptions = TranscriptionOptions> = MediaRoute.AnyRoute<
TranscriptionRequestFor<Options>,
TranscriptionEvent,
TranscriptionResponse
>
export type TranscriptionRoute = MediaRoute.AnyRoute<TranscriptionRequestFor, TranscriptionEvent, TranscriptionResponse>
export class TranscriptionModel<Options extends TranscriptionOptions = TranscriptionOptions> extends MediaModel<
TranscriptionRoute<Options>,
TranscriptionRoute,
Options
> {
declare protected readonly _TranscriptionModel: void
static make<Options extends TranscriptionOptions = TranscriptionOptions>(
input: MediaModel.Input<TranscriptionRoute<Options>>,
) {
return new TranscriptionModel<Options>(input)
}
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
static fromRoute<Options extends TranscriptionOptions>(
route: TranscriptionModel.InlineRouteInput<Options>,
route: MediaModel.InlineRouteInput<TranscriptionRequestFor<Options>, TranscriptionResponse>,
input: MediaRoute.ModelInput,
): TranscriptionModel<Options>
static fromRoute<Options extends TranscriptionOptions, Frame, State>(
route: TranscriptionModel.StreamRouteInput<Options, Frame, State>,
route: MediaModel.StreamRouteInput<TranscriptionRequestFor<Options>, TranscriptionEvent, Frame, State>,
input: MediaRoute.ModelInput,
): TranscriptionModel<Options>
static fromRoute<Options extends TranscriptionOptions, Token>(
route: TranscriptionModel.QueuedRouteInput<Options, Token>,
route: MediaModel.QueuedRouteInput<TranscriptionRequestFor<Options>, TranscriptionResponse, Token>,
input: MediaRoute.ModelInput,
): TranscriptionModel<Options>
static fromRoute<Options extends TranscriptionOptions, Frame, State, Token>(
route: TranscriptionModel.RouteInput<Options, Frame, State, Token>,
route: MediaModel.AnyRouteInput<
TranscriptionRequestFor<Options>,
TranscriptionEvent,
TranscriptionResponse,
Frame,
State,
Token
>,
input: MediaRoute.ModelInput,
) {
return new TranscriptionModel<Options>({
id: input.id,
provider: route.protocol.provider,
http: input.http,
route: composeAnyRoute(route, input, collectResponse),
route: composeRoute(route, input, collectResponse) as TranscriptionRoute,
})
}
}
export namespace TranscriptionModel {
export type InlineRouteInput<Options extends TranscriptionOptions = TranscriptionOptions> = MediaModel.RouteInput<
TranscriptionRequestFor<Options>,
MediaProtocol.Inline<TranscriptionRequestFor<Options>, TranscriptionResponse>
>
export type StreamRouteInput<
Options extends TranscriptionOptions = TranscriptionOptions,
Frame = unknown,
State = unknown,
> = MediaModel.RouteInput<
MediaProtocol.Addressed<TranscriptionRequestFor<Options>>,
MediaProtocol.Streamed<TranscriptionRequestFor<Options>, TranscriptionEvent, Frame, State>
>
export type QueuedRouteInput<
Options extends TranscriptionOptions = TranscriptionOptions,
Token = unknown,
> = MediaModel.RouteInput<
TranscriptionRequestFor<Options>,
MediaProtocol.Queued<TranscriptionRequestFor<Options>, TranscriptionResponse, Token>
>
export type RouteInput<
Options extends TranscriptionOptions = TranscriptionOptions,
Frame = unknown,
State = unknown,
Token = unknown,
> = MediaModel.AnyRouteInput<
TranscriptionRequestFor<Options>,
TranscriptionEvent,
TranscriptionResponse,
Frame,
State,
Token
>
}
export const TranscriptionModelSchema = Schema.declare(
(value): value is TranscriptionModel => value instanceof TranscriptionModel,
{ expected: "Transcription.Model" },
@@ -212,10 +170,6 @@ export const TranscriptionEvent = Object.assign(transcriptionEventTagged, {
})
export type TranscriptionEvent = Schema.Schema.Type<typeof transcriptionEventTagged>
export const responseEvents = (response: TranscriptionResponse): ReadonlyArray<TranscriptionEvent> => [
TranscriptionFinishEvent.make({ ...response }),
]
const collectResponse = (events: ReadonlyArray<TranscriptionEvent>): Effect.Effect<TranscriptionResponse> => {
const finish = events.find(TranscriptionEvent.is.finish)
// Every transcription protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
@@ -243,11 +197,7 @@ export function request(input: TranscriptionRequest | TranscriptionRequestInput)
const requestEffect = (input: TranscriptionRequest | TranscriptionRequestInput) => tryRequest(() => request(input))
export function generate<const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<TranscriptionResponse, AIError, Service>
export function generate(
input: TranscriptionRequest,
input: TranscriptionRequest | TranscriptionRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<TranscriptionResponse, AIError, Service>
export function generate(input: TranscriptionRequest | TranscriptionRequestInput, options?: AwaitOptions) {
@@ -255,11 +205,7 @@ export function generate(input: TranscriptionRequest | TranscriptionRequestInput
}
export function stream<const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<TranscriptionEvent, AIError, Service>
export function stream(
input: TranscriptionRequest,
input: TranscriptionRequest | TranscriptionRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<TranscriptionEvent, AIError, Service>
export function stream(input: TranscriptionRequest | TranscriptionRequestInput, options?: AwaitOptions) {
@@ -268,15 +214,14 @@ export function stream(input: TranscriptionRequest | TranscriptionRequestInput,
/** Inline and streaming routes fail with `UnsupportedOperation`. */
export function start<const Model extends TranscriptionModel>(
input: TranscriptionRequestInput<Model>,
input: TranscriptionRequest | TranscriptionRequestInput<Model>,
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service>
export function start(input: TranscriptionRequest): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service>
export function start(input: TranscriptionRequest | TranscriptionRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => TranscriptionClient.start(request)))
}
export const resume = <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
export const resume = (
model: TranscriptionModel,
token: unknown,
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service> => TranscriptionClient.resume(model, token)
+16 -84
View File
@@ -1,98 +1,30 @@
import { Context, Effect, Layer, Stream } from "effect"
import { resultEvents, type AwaitOptions, type Generation } from "./generation.js"
import { RequestExecutor } from "./route/executor.js"
import type { AIError } from "./schema/index.js"
import { Context } from "effect"
import { MediaClient } from "./media-client.js"
import {
responseEvents,
VideoOutputEvent,
VideoFinishEvent,
type VideoEvent,
type VideoModel,
type VideoOptions,
type VideoRequestFor,
type VideoResponse,
} from "./video.js"
export interface Interface {
readonly start: <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
) => Effect.Effect<Generation<VideoResponse>, AIError>
readonly resume: <Options extends VideoOptions>(
model: VideoModel<Options>,
token: unknown,
) => Effect.Effect<Generation<VideoResponse>, AIError>
readonly generate: <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
options?: AwaitOptions,
) => Effect.Effect<VideoResponse, AIError>
readonly stream: <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
options?: AwaitOptions,
) => Stream.Stream<VideoEvent, AIError>
}
export type Interface = MediaClient.Interface<VideoRequestFor, VideoEvent, VideoResponse>
export class VideoClientService extends Context.Service<VideoClientService, Interface>()("@opencode/VideoClient") {}
export const Service = VideoClientService
export type Service = VideoClientService
export const start = <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.start(request)
})
export const resume = <Options extends VideoOptions>(
model: VideoModel<Options>,
token: unknown,
): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.resume(model, token)
})
export const generate = <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
options?: AwaitOptions,
): Effect.Effect<VideoResponse, AIError, Service> =>
Effect.gen(function* () {
const client = yield* Service
return yield* client.generate(request, options)
})
export const stream = <Options extends VideoOptions>(
request: VideoRequestFor<Options>,
options?: AwaitOptions,
): Stream.Stream<VideoEvent, AIError, Service> =>
Stream.unwrap(
Effect.gen(function* () {
const client = yield* Service
return client.stream(request, options)
}),
)
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
Service,
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const start = <Options extends VideoOptions>(request: VideoRequestFor<Options>) =>
request.model.route.start(request, executor.execute)
return Service.of({
start,
resume: (model, token) => model.route.resume(model, token, executor.execute),
generate: (request, options) => start(request).pipe(Effect.flatMap((generation) => generation.await(options))),
stream: (request, options) =>
Stream.unwrap(
start(request).pipe(Effect.map((generation) => resultEvents(generation, responseEvents, options))),
),
})
}),
)
export const VideoClient = {
Service,
layer,
start,
resume,
generate,
stream,
...MediaClient.make(Service, {
modality: "video",
responseEvents: (response: VideoResponse) => [
...response.videos.map((video, index) => VideoOutputEvent.make({ index, video })),
VideoFinishEvent.make({
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
],
}),
} as const
+37 -41
View File
@@ -3,7 +3,6 @@ import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./gen
import { Media } from "./media.js"
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
import { MediaRoute } from "./route/media.js"
import type { MediaProtocol } from "./route/media-protocol.js"
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
import { VideoClient, Service } from "./video-client.js"
@@ -11,41 +10,39 @@ import { VideoClient, Service } from "./video-client.js"
// Model
// ---------------------------------------------------------------------------
export type VideoOptions = Record<string, unknown>
export type VideoOptions = MediaModel.Options
export type VideoRoute<Options extends VideoOptions = VideoOptions> = MediaRoute.QueuedRoute<
VideoRequestFor<Options>,
VideoResponse
>
export type VideoRoute = MediaRoute.AnyRoute<VideoRequestFor, VideoEvent, VideoResponse>
export class VideoModel<Options extends VideoOptions = VideoOptions> extends MediaModel<VideoRoute<Options>, Options> {
export class VideoModel<Options extends VideoOptions = VideoOptions> extends MediaModel<VideoRoute, Options> {
declare protected readonly _VideoModel: void
static make<Options extends VideoOptions = VideoOptions>(input: MediaModel.Input<VideoRoute<Options>>) {
return new VideoModel<Options>(input)
}
/** Compose a queued video protocol with its canonical start path into a model for one deployment. */
static fromRoute<Options extends VideoOptions = VideoOptions, Token = unknown>(
route: VideoModel.RouteInput<Options, Token>,
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
static fromRoute<Options extends VideoOptions>(
route: MediaModel.InlineRouteInput<VideoRequestFor<Options>, VideoResponse>,
input: MediaRoute.ModelInput,
): VideoModel<Options>
static fromRoute<Options extends VideoOptions, Frame, State>(
route: MediaModel.StreamRouteInput<VideoRequestFor<Options>, VideoEvent, Frame, State>,
input: MediaRoute.ModelInput,
): VideoModel<Options>
static fromRoute<Options extends VideoOptions, Token>(
route: MediaModel.QueuedRouteInput<VideoRequestFor<Options>, VideoResponse, Token>,
input: MediaRoute.ModelInput,
): VideoModel<Options>
static fromRoute<Options extends VideoOptions, Frame, State, Token>(
route: MediaModel.AnyRouteInput<VideoRequestFor<Options>, VideoEvent, VideoResponse, Frame, State, Token>,
input: MediaRoute.ModelInput,
) {
return new VideoModel<Options>({
id: input.id,
provider: route.protocol.provider,
http: input.http,
route: composeRoute(MediaRoute.queued, route, input),
route: composeRoute(route, input, collectResponse) as VideoRoute,
})
}
}
export namespace VideoModel {
export type RouteInput<Options extends VideoOptions = VideoOptions, Token = unknown> = MediaModel.RouteInput<
VideoRequestFor<Options>,
MediaProtocol.Queued<VideoRequestFor<Options>, VideoResponse, Token>
>
}
export const VideoModelSchema = Schema.declare((value): value is VideoModel => value instanceof VideoModel, {
expected: "Video.Model",
})
@@ -149,15 +146,19 @@ export const VideoEvent = Object.assign(videoEventTagged, {
})
export type VideoEvent = Schema.Schema.Type<typeof videoEventTagged>
/** A completed response expanded into the streaming event shape. */
export const responseEvents = (response: VideoResponse): ReadonlyArray<VideoEvent> => [
...response.videos.map((video, index) => VideoOutputEvent.make({ index, video })),
VideoFinishEvent.make({
usage: response.usage,
notices: response.notices,
providerMetadata: response.providerMetadata,
}),
]
const collectResponse = (events: ReadonlyArray<VideoEvent>): Effect.Effect<VideoResponse> => {
const finish = events.find(VideoEvent.is.finish)
// A streaming video protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
if (finish === undefined) return Effect.die(new Error("The video stream completed without a finish event"))
return Effect.succeed(
new VideoResponse({
videos: events.filter(VideoEvent.is.video).map((event) => event.video),
usage: finish.usage,
notices: finish.notices,
providerMetadata: finish.providerMetadata,
}),
)
}
// ---------------------------------------------------------------------------
// Request-shaped call API
@@ -178,33 +179,28 @@ export function request(input: VideoRequest | VideoRequestInput) {
const requestEffect = (input: VideoRequest | VideoRequestInput) => tryRequest(() => request(input))
export function start<const Model extends VideoModel>(
input: VideoRequestInput<Model>,
input: VideoRequest | VideoRequestInput<Model>,
): Effect.Effect<Generation<VideoResponse>, AIError, Service>
export function start(input: VideoRequest): Effect.Effect<Generation<VideoResponse>, AIError, Service>
export function start(input: VideoRequest | VideoRequestInput) {
return requestEffect(input).pipe(Effect.flatMap((request) => VideoClient.start(request)))
}
export function generate<const Model extends VideoModel>(
input: VideoRequestInput<Model>,
input: VideoRequest | VideoRequestInput<Model>,
options?: AwaitOptions,
): Effect.Effect<VideoResponse, AIError, Service>
export function generate(input: VideoRequest, options?: AwaitOptions): Effect.Effect<VideoResponse, AIError, Service>
export function generate(input: VideoRequest | VideoRequestInput, options?: AwaitOptions) {
return requestEffect(input).pipe(Effect.flatMap((request) => VideoClient.generate(request, options)))
}
/** Rebuild a generation handle from a persisted `Generation.token`, refreshing its status once. */
export const resume = <Options extends VideoOptions>(
model: VideoModel<Options>,
token: unknown,
): Effect.Effect<Generation<VideoResponse>, AIError, Service> => VideoClient.resume(model, token)
export const resume = (model: VideoModel, token: unknown): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
VideoClient.resume(model, token)
export function stream<const Model extends VideoModel>(
input: VideoRequestInput<Model>,
input: VideoRequest | VideoRequestInput<Model>,
options?: AwaitOptions,
): Stream.Stream<VideoEvent, AIError, Service>
export function stream(input: VideoRequest, options?: AwaitOptions): Stream.Stream<VideoEvent, AIError, Service>
export function stream(input: VideoRequest | VideoRequestInput, options?: AwaitOptions) {
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => VideoClient.stream(request, options))))
}
+13
View File
@@ -54,6 +54,19 @@ import { TestLLM } from "@opencode/ai/testing"
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
describe("public exports", () => {
test("modality, provider, and protocol entrypoints load first in a fresh process", async () => {
const results = await Promise.all(
["image", "video", "speech", "transcription", "providers", "protocols"].map(async (entry) => {
const child = Bun.spawn(
[process.execPath, "-e", `await import(${JSON.stringify(`${import.meta.dir}/../src/${entry}.ts`)})`],
{ stderr: "pipe" },
)
return { entry, exitCode: await child.exited, stderr: await new Response(child.stderr).text() }
}),
)
expect(results.filter((result) => result.exitCode !== 0)).toEqual([])
})
test("root exposes app-facing runtime APIs", () => {
expect(LLM.request).toBeFunction()
expect(LLMClient.Service).toBeFunction()
@@ -30,7 +30,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":50}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":50}"
},
{
"direction": "server",
@@ -525,7 +525,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_hejtTYDa1IfLyNIzb3fq9gJs\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":50,\"previous_response_id\":\"resp_0d9a44b6df400533016aa8c8e36de887d1be260913d131b2ca\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_hejtTYDa1IfLyNIzb3fq9gJs\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":50,\"previous_response_id\":\"resp_0d9a44b6df400533016aa8c8e36de887d1be260913d131b2ca\"}"
},
{
"direction": "server",
@@ -30,7 +30,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
},
{
"direction": "server",
@@ -109,7 +109,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a6277dd90b94da1016aa8c946e33487d1b725d8e9dc874d82\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMlHtG6yltzgW_UjUcYxvl2hoMkk7cSEH5SJMe9CR5gKIaCwCh4peUo8XZrd-EU-tPCXthv7JzHxYXGVG1fIgTJ7BjBoOh-jgd_oGlyCHj2jVPj7nVoB763tZHdyw_ovL3V7GpJ6VeLGIsRGqNTnBz47ZuKikTKrbTDupn6fT2wOM_69zwdg4-UVnoF2J9E_jIS0E5XRtRwOidqANl61HCwo7LV-Ut1aqCbXb-59vkrVGWPxD_8n4smf7Qjywc0FCH9zwuEDX4cPdqj3MmjvYPrn0jand9LKkAy9rblaKFJSQfVuritbHrdQrnF7hLxu7QCpehOzNXOkpNEqTmXnAZjfMc53hq-ahH6_KoyYlZpompwyGPngFeI2fKRqP8rlpDilD1BHBsh-bl7kXzI8HQ_jameXwPZ1La6gjtFkThXp53BD6BOx11SB9Nypqolu2at5rR32UYcGrYeGtTu-HmYGp0oHVFkHumTNuHKmGXV14dI9swgryfygLhX8EAJrWrjm3e8rvAkHKpAZ0IkmlCcp15UCFDKNeS560fQVRaKXWnQ7m0Ih3C1xG0ifJ89j27c8GHo1kAhEJk-lSB1-FZr9Ls_w7N772kmZ2a7LLswu3kNW78kPas_CtcOnBOHwE1DhcVh5YpxwNftOHZnK1v8NPNF8EWqio4ArZy1thMCzH5zXBNcFzgd8tePMFukBblSP8QGQoVYRqTne2sFoOZXjslXnDvEe-Ycj8X38zWgRiwAk7guOloFC7Se36KCDP357773Vah86gWCt55mSEyhVW_GF1oTuHvJ18GZsXcyN21scF1PSr8YaCM_jR7ZkU1GYXbQK2Y4oTAV9XDptQA5YzEREnn7muC_6v5ZTAglZF1lhn9Q0NwmylZEAXJdSGHaqXt1Hv-vQlprA_9m22vrreBOTLPnVK946J8absKrwfe-jK_1n_9YQR43uwH8XwFBFND0c4lICCQGbxwM8pX4ACWR0c19aORCYm-M5FrJsxmG29_aDVNhcvkoQ3mlP7ITQeqzkrjfytSwLb2BYpXYZKjEHNfV9j3JoxJobUkK5hrxXBhTvZzbVBnE0LSXQNwR-JAcOliP_jXBEeQ-28B-aGW8TI1vEP2i260QKgzOzPC-pOoFp0-vCvxojNO0kE16ECVTLfDNLNGSMZs9vekdLJP2akBp6PsUXsQUTbWO_wr1E2oNU7ctfMRxoh-yP0ZW2_xz_NjE72O5LF_6zuDVV7Q==\"},{\"type\":\"message\",\"id\":\"msg_0a6277dd90b94da1016aa8c947253887d184c150fcbcbcd8da\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a6277dd90b94da1016aa8c946e33487d1b725d8e9dc874d82\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMlHtG6yltzgW_UjUcYxvl2hoMkk7cSEH5SJMe9CR5gKIaCwCh4peUo8XZrd-EU-tPCXthv7JzHxYXGVG1fIgTJ7BjBoOh-jgd_oGlyCHj2jVPj7nVoB763tZHdyw_ovL3V7GpJ6VeLGIsRGqNTnBz47ZuKikTKrbTDupn6fT2wOM_69zwdg4-UVnoF2J9E_jIS0E5XRtRwOidqANl61HCwo7LV-Ut1aqCbXb-59vkrVGWPxD_8n4smf7Qjywc0FCH9zwuEDX4cPdqj3MmjvYPrn0jand9LKkAy9rblaKFJSQfVuritbHrdQrnF7hLxu7QCpehOzNXOkpNEqTmXnAZjfMc53hq-ahH6_KoyYlZpompwyGPngFeI2fKRqP8rlpDilD1BHBsh-bl7kXzI8HQ_jameXwPZ1La6gjtFkThXp53BD6BOx11SB9Nypqolu2at5rR32UYcGrYeGtTu-HmYGp0oHVFkHumTNuHKmGXV14dI9swgryfygLhX8EAJrWrjm3e8rvAkHKpAZ0IkmlCcp15UCFDKNeS560fQVRaKXWnQ7m0Ih3C1xG0ifJ89j27c8GHo1kAhEJk-lSB1-FZr9Ls_w7N772kmZ2a7LLswu3kNW78kPas_CtcOnBOHwE1DhcVh5YpxwNftOHZnK1v8NPNF8EWqio4ArZy1thMCzH5zXBNcFzgd8tePMFukBblSP8QGQoVYRqTne2sFoOZXjslXnDvEe-Ycj8X38zWgRiwAk7guOloFC7Se36KCDP357773Vah86gWCt55mSEyhVW_GF1oTuHvJ18GZsXcyN21scF1PSr8YaCM_jR7ZkU1GYXbQK2Y4oTAV9XDptQA5YzEREnn7muC_6v5ZTAglZF1lhn9Q0NwmylZEAXJdSGHaqXt1Hv-vQlprA_9m22vrreBOTLPnVK946J8absKrwfe-jK_1n_9YQR43uwH8XwFBFND0c4lICCQGbxwM8pX4ACWR0c19aORCYm-M5FrJsxmG29_aDVNhcvkoQ3mlP7ITQeqzkrjfytSwLb2BYpXYZKjEHNfV9j3JoxJobUkK5hrxXBhTvZzbVBnE0LSXQNwR-JAcOliP_jXBEeQ-28B-aGW8TI1vEP2i260QKgzOzPC-pOoFp0-vCvxojNO0kE16ECVTLfDNLNGSMZs9vekdLJP2akBp6PsUXsQUTbWO_wr1E2oNU7ctfMRxoh-yP0ZW2_xz_NjE72O5LF_6zuDVV7Q==\"},{\"type\":\"message\",\"id\":\"msg_0a6277dd90b94da1016aa8c947253887d184c150fcbcbcd8da\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
},
{
"direction": "server",
@@ -30,7 +30,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
},
{
"direction": "server",
@@ -109,7 +109,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30,\"previous_response_id\":\"resp_01cc0cda24c36acf016aa8ca3c1d3c87d1853283f43675e411\"}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30,\"previous_response_id\":\"resp_01cc0cda24c36acf016aa8ca3c1d3c87d1853283f43675e411\"}"
},
{
"direction": "server",
@@ -119,7 +119,7 @@
{
"direction": "client",
"kind": "text",
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_01cc0cda24c36acf016aa8ca3ced8c87d1a14c5c6a2ced8544\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMo9M9B15LsV1CLsXpNJpI08GiwCAK97hTheQ2s1lAkKgARMs1HIIXVeAr-tbUq88yN51fiQE8HvGwdF9ZcNI6bZle8D2PS7m8O7UE7C1Z_HX_fz2f0pRHyRfWpVAT2gYQWIDeZu6UdgxbKPBW0lXWzlk_relHG5x6nXkYzZoEeVasqavWoyMMSX7cexe-IYJh6e_3DgRpOchueS8Z-70P0w5R83Ea7UXZQNhMA0yDEYu_td2PmE2Pd2PUTOB3mxF2pb1z7-2t6S0UryhHx0az7Gh2eT60GGUqz9CIZzNE_FX--tszeuO0eI92Cen5tirOUHBTyyDqE0eG26DRl_p_U-xDZwaQONbtYbvkvrj-G7FA2oZXxjJPHuQZsNgBslXS-H0KT1lx3Y8XJ9QMVjFLFaucFG64wCmXPfCH8dYtX_YqfYQR4lwNfiSbyJEX2oDvTVVD_aCJ9NRo7c0aCTtmKBvr6fvvAy3MAFxAp_Sm2nMx4P5GYO4qAmJDByywKw-VK1vHlv3NRmVsAgbArIFgm-axoCs2PLpvZjDqeQGPavaq8zKWTyZYqBsEzKZUtGOZfYjD4mud0Z08I4i2H4K-L00ccVauode3548ZipOIuslbhJxonQXsF6TFdW2Hj8E5JjoEr5IbmwHyI0PBcDWW5AmkjHLwr9v08mFppRoD-2wzPAd5igROAuUbvJhiQd2A-uOaohwMjdpFxrjyUqgGTlI5g7tmI1ceeQWms0bKm8Pd0wIqVM3Nq6YvV7XfEyeogfRMVUQezr_lES42ZMVAoKBSFzMysDwCFkhVNVclcTUpcUUbVp21FChG7Ag-xuq8Cl6OGLA8nWX1C0aCf2HNa-n3dkYr1DtUziurh1MD-UIs5jdGiq3ptrc0VaVZwNdD4jVfAoHB_Ws7GiISXuclfpqsG3DTJEfzlbukI1vxXrt3FArsHiQvQjW5UM7gGel32M6p8AlXRxnez9PgIuU1WrtBUJetk7m39AZwp_aqbqC-AJ-MF70xP1VJZwFN-GeNL3VZsRHePFG4h7Pj---CCZRGlmzuE1-b-sIE7Bn_gbue_qHFMZJhAY55MO25vfZbSoLWHZCGmLMjJVHOYCPoy6l7zyxNmoIcC58QILNWal31KLCDmCsASmZC-xQRjyFwt-kvLbvk38Dc02IKcP3ujhf6WRRr1A0hh1K-gXmv_XI_MUFDcOVIjjhB-rXgjWSCoKfaKI9GCIsb9mnNaBq65BWg==\"},{\"type\":\"message\",\"id\":\"msg_01cc0cda24c36acf016aa8ca3d4a0087d1950121aed4802434\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_01cc0cda24c36acf016aa8ca3ced8c87d1a14c5c6a2ced8544\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMo9M9B15LsV1CLsXpNJpI08GiwCAK97hTheQ2s1lAkKgARMs1HIIXVeAr-tbUq88yN51fiQE8HvGwdF9ZcNI6bZle8D2PS7m8O7UE7C1Z_HX_fz2f0pRHyRfWpVAT2gYQWIDeZu6UdgxbKPBW0lXWzlk_relHG5x6nXkYzZoEeVasqavWoyMMSX7cexe-IYJh6e_3DgRpOchueS8Z-70P0w5R83Ea7UXZQNhMA0yDEYu_td2PmE2Pd2PUTOB3mxF2pb1z7-2t6S0UryhHx0az7Gh2eT60GGUqz9CIZzNE_FX--tszeuO0eI92Cen5tirOUHBTyyDqE0eG26DRl_p_U-xDZwaQONbtYbvkvrj-G7FA2oZXxjJPHuQZsNgBslXS-H0KT1lx3Y8XJ9QMVjFLFaucFG64wCmXPfCH8dYtX_YqfYQR4lwNfiSbyJEX2oDvTVVD_aCJ9NRo7c0aCTtmKBvr6fvvAy3MAFxAp_Sm2nMx4P5GYO4qAmJDByywKw-VK1vHlv3NRmVsAgbArIFgm-axoCs2PLpvZjDqeQGPavaq8zKWTyZYqBsEzKZUtGOZfYjD4mud0Z08I4i2H4K-L00ccVauode3548ZipOIuslbhJxonQXsF6TFdW2Hj8E5JjoEr5IbmwHyI0PBcDWW5AmkjHLwr9v08mFppRoD-2wzPAd5igROAuUbvJhiQd2A-uOaohwMjdpFxrjyUqgGTlI5g7tmI1ceeQWms0bKm8Pd0wIqVM3Nq6YvV7XfEyeogfRMVUQezr_lES42ZMVAoKBSFzMysDwCFkhVNVclcTUpcUUbVp21FChG7Ag-xuq8Cl6OGLA8nWX1C0aCf2HNa-n3dkYr1DtUziurh1MD-UIs5jdGiq3ptrc0VaVZwNdD4jVfAoHB_Ws7GiISXuclfpqsG3DTJEfzlbukI1vxXrt3FArsHiQvQjW5UM7gGel32M6p8AlXRxnez9PgIuU1WrtBUJetk7m39AZwp_aqbqC-AJ-MF70xP1VJZwFN-GeNL3VZsRHePFG4h7Pj---CCZRGlmzuE1-b-sIE7Bn_gbue_qHFMZJhAY55MO25vfZbSoLWHZCGmLMjJVHOYCPoy6l7zyxNmoIcC58QILNWal31KLCDmCsASmZC-xQRjyFwt-kvLbvk38Dc02IKcP3ujhf6WRRr1A0hh1K-gXmv_XI_MUFDcOVIjjhB-rXgjWSCoKfaKI9GCIsb9mnNaBq65BWg==\"},{\"type\":\"message\",\"id\":\"msg_01cc0cda24c36acf016aa8ca3d4a0087d1950121aed4802434\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
},
{
"direction": "server",
File diff suppressed because one or more lines are too long
@@ -26,7 +26,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"max_output_tokens\":120,\"stream\":true}"
},
"response": {
"status": 200,
@@ -44,7 +44,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_052e7ec551f55289016aa8c8d63eac87d19d6b921611f123e7\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMjWZ2Eei8_Gf-5FeEFAYp-gSzFL4D4lQBKL_fyyTYXv5iJ-2jql1mOq0wZpqHL8O9MWxebQGW56Ahd-p21qrDD52CyUBqKKlF87eC1d-cTgjXQlFMsPxvwyQeuU6A2l8tanTtJ48sKtzZtHrDuBXZ35u-lONnovjFGMX3Q83xoqG_um_w5rT420TA_SyU4fGt7oiQvOPS1q4PNo97O824oRnI7n_BC1jPYCaJhl2I1rPJg4afuOpjG-u7JcXRD4JPwZdqMa5o2d0uDKHuUYwP25qiPKKDqTTqFka5cDJjZNPF3ZkVHR-cagjZGvMnizXXxgUpPJ9j83gqY4QJLKCkzcaBj9H7mAL-v9yl4I5kn_9_DhpMILs2SZkC8AvIYNgmel3sDV_BG4XZ2JXciZz86ukQ6DwXgQqS4HOB91g-sGHOWMU1ohsZlEvvJBjGkJ_rAdXVMqAbvi2zvE3_NI4sTAGUrIugGJePrQYTe8gqL8f9NsYac6pzHNQL1e_jQNUvp49bu7EsPzCP3KPYVvZCFohDdwe7sMd6wrztrCJwM4CLdAQK61A7sYzU0HyglLtPidmS5QkSmV6U_xgih7JbKnY0oAeCyYw4ADYqdNTi0axmBErh-lbh-XKNG_TnoMa-2IS3X041N8OsDfSdQp3QsAm53fF8seQuLFa27Iaq2etMj3yGeqWVjA-Mae3K34mt2YPGjQ-HbIOMVmYXBLzNr-s2fT35Sp6SDEsFyvzXb0Vij58s1wW5zkuKgaJiroGgkY86NImuaa3_-wpMK3_9O_wwAbRwV4uBCVzT_rY6rDQHR9-VkM0MbGK8drbdtjXwy3KtAzkux4N4g2nadYU0IIIEUNj_JChUFSHC7VRg7L7LpZMgAFGwHmaUyzQt31LyiVix9WFtcKfBgzehoRV6vstln-oBRd-vFjUW-7WLm7R_lFNHQZ0CKUvKCSpQxdIevczpYT0_lQiDU7Rfp1UBBnicndpq4YQwgRppdZX-QHG5IZxxHNWYdIBtn3eO3ooDXmI-rYryyVcFG6VY5xqssf8GgXqgNy84X_SgYfhGmNiPPQTl5wIBa49f1sxAI-7ri1xLr6FJhQMasOoxm_VuZYNkq6NrAXprtKa70cBRpm1wWBNcPdMul4GNMDQatQZRDENcNLe45iMX-HB1YYSvyc-y5rfSJkP4EgKXC-OqcrzxAlRfB3Bm4jOQL_PSsNBbh26CWAdAYSftM88fbnuqZX2w==\"},{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}],\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_052e7ec551f55289016aa8c8d63eac87d19d6b921611f123e7\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMjWZ2Eei8_Gf-5FeEFAYp-gSzFL4D4lQBKL_fyyTYXv5iJ-2jql1mOq0wZpqHL8O9MWxebQGW56Ahd-p21qrDD52CyUBqKKlF87eC1d-cTgjXQlFMsPxvwyQeuU6A2l8tanTtJ48sKtzZtHrDuBXZ35u-lONnovjFGMX3Q83xoqG_um_w5rT420TA_SyU4fGt7oiQvOPS1q4PNo97O824oRnI7n_BC1jPYCaJhl2I1rPJg4afuOpjG-u7JcXRD4JPwZdqMa5o2d0uDKHuUYwP25qiPKKDqTTqFka5cDJjZNPF3ZkVHR-cagjZGvMnizXXxgUpPJ9j83gqY4QJLKCkzcaBj9H7mAL-v9yl4I5kn_9_DhpMILs2SZkC8AvIYNgmel3sDV_BG4XZ2JXciZz86ukQ6DwXgQqS4HOB91g-sGHOWMU1ohsZlEvvJBjGkJ_rAdXVMqAbvi2zvE3_NI4sTAGUrIugGJePrQYTe8gqL8f9NsYac6pzHNQL1e_jQNUvp49bu7EsPzCP3KPYVvZCFohDdwe7sMd6wrztrCJwM4CLdAQK61A7sYzU0HyglLtPidmS5QkSmV6U_xgih7JbKnY0oAeCyYw4ADYqdNTi0axmBErh-lbh-XKNG_TnoMa-2IS3X041N8OsDfSdQp3QsAm53fF8seQuLFa27Iaq2etMj3yGeqWVjA-Mae3K34mt2YPGjQ-HbIOMVmYXBLzNr-s2fT35Sp6SDEsFyvzXb0Vij58s1wW5zkuKgaJiroGgkY86NImuaa3_-wpMK3_9O_wwAbRwV4uBCVzT_rY6rDQHR9-VkM0MbGK8drbdtjXwy3KtAzkux4N4g2nadYU0IIIEUNj_JChUFSHC7VRg7L7LpZMgAFGwHmaUyzQt31LyiVix9WFtcKfBgzehoRV6vstln-oBRd-vFjUW-7WLm7R_lFNHQZ0CKUvKCSpQxdIevczpYT0_lQiDU7Rfp1UBBnicndpq4YQwgRppdZX-QHG5IZxxHNWYdIBtn3eO3ooDXmI-rYryyVcFG6VY5xqssf8GgXqgNy84X_SgYfhGmNiPPQTl5wIBa49f1sxAI-7ri1xLr6FJhQMasOoxm_VuZYNkq6NrAXprtKa70cBRpm1wWBNcPdMul4GNMDQatQZRDENcNLe45iMX-HB1YYSvyc-y5rfSJkP4EgKXC-OqcrzxAlRfB3Bm4jOQL_PSsNBbh26CWAdAYSftM88fbnuqZX2w==\"},{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}],\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"max_output_tokens\":40,\"stream\":true}"
},
"response": {
"status": 200,
@@ -24,7 +24,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"max_output_tokens\":120,\"stream\":true}"
},
"response": {
"status": 200,
@@ -25,7 +25,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":80,\"stream\":true}"
},
"response": {
"status": 200,
@@ -43,7 +43,7 @@
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"id\":\"fc_09525c04931d1487016aa8c8d8e12887d193bd327a0f313bd7\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"id\":\"fc_09525c04931d1487016aa8c8d8e12887d193bd327a0f313bd7\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":80,\"stream\":true}"
},
"response": {
"status": 200,
+3 -3
View File
@@ -7,7 +7,6 @@ import {
type ImageModelOptions,
type ImageOptions,
type ImageRequestFor,
type ImageRoute,
} from "../src/index.js"
import type { Service } from "../src/image-client.js"
import { Anthropic, BlackForestLabs, Google, OpenAI, Stability, XAI, ZAI } from "../src/providers.js"
@@ -21,8 +20,7 @@ type GoogleLikeOptions = {
readonly thinkingLevel?: "LOW" | "HIGH"
} & Record<string, unknown>
declare const route: ImageRoute<GoogleLikeOptions>
const google = ImageModel.make<GoogleLikeOptions>({ id: "gemini-image", provider: "google", route })
declare const google: ImageModel<GoogleLikeOptions>
// @ts-expect-error Extracted model options retain known provider fields.
const invalidGoogleOptions: ImageModelOptions<typeof google> = { imageSize: "8K" }
void invalidGoogleOptions
@@ -152,6 +150,8 @@ Image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { quality:
Image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { userID: 1 } })
declare const generic: ImageModel<ImageOptions>
const widenImage = <Options extends ImageOptions>(model: ImageModel<Options>): ImageModel => model
void widenImage
Image.generate({ model: generic, prompt: "A lighthouse", providerOptions: { arbitrary: true } })
const explicitAsset: Media.Asset = Media.url("https://example.com/image.png")
void explicitAsset
@@ -6,6 +6,7 @@ import {
type LanguageModelProviderOptions,
type ProviderOptions,
} from "../src/index.js"
import { ai } from "../src/promise.js"
import { OpenAIChat } from "../src/protocols.js"
interface ExampleOptions {
@@ -31,6 +32,10 @@ const generated = LLM.generate(LLM.request({ model, prompt: "Hello" }))
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, LLMClientService>>
const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" }))
type StreamClientRequirements = Assert<Equal<StreamRequirements<typeof streamed>, LLMClientService>>
const generatedFromInput = LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } })
type InputGenerateRequirements = Assert<Equal<Requirements<typeof generatedFromInput>, LLMClientService>>
const streamedFromInput = LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } })
type InputStreamRequirements = Assert<Equal<StreamRequirements<typeof streamedFromInput>, LLMClientService>>
LLM.request({
model,
@@ -39,6 +44,11 @@ LLM.request({
providerOptions: { mode: "slow" },
})
// @ts-expect-error Direct input keeps the selected model's provider option types.
LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
// @ts-expect-error Stream input keeps the selected model's provider option types.
LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
const generatedObject = LLM.generateObject({
model,
prompt: "Hello",
@@ -69,5 +79,16 @@ const options: LanguageModelProviderOptions<typeof model> = { mode: "fast" }
void (options satisfies LanguageModelProviderOptions<typeof model>)
void (true satisfies GenerateRequirements)
void (true satisfies StreamClientRequirements)
void (true satisfies InputGenerateRequirements)
void (true satisfies InputStreamRequirements)
void (true satisfies GenerateObjectRequirements)
void (true satisfies GenerateDynamicObjectRequirements)
void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } })
void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } })
void ai.llm.generate(ai.llm.request({ model, prompt: "Hello" }))
void ai.llm.stream(ai.llm.request({ model, prompt: "Hello" }))
// @ts-expect-error Promise direct input keeps the selected model's provider option types.
void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
// @ts-expect-error Promise stream input keeps the selected model's provider option types.
void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
+25 -2
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { CacheHint, LLM, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js"
import { Effect, Schema, Stream } from "effect"
import { CacheHint, LLM, LLMEvent, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js"
import { OpenAI } from "../src/providers.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
import {
@@ -13,6 +14,8 @@ import {
ToolDefinition,
ToolResultPart,
} from "../src/schema/index.js"
import { fixedResponse } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
const chatRoute = OpenAIChat.route
const responsesRoute = OpenAIResponses.route
@@ -240,6 +243,26 @@ describe("llm constructors", () => {
expect(request.messages.map((message) => message.role)).toEqual(["user", "system"])
})
test("generates and streams from input or a prebuilt request", async () => {
const model = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).chat("gpt-4o-mini")
const layer = fixedResponse(
sseEvents({ choices: [{ delta: { content: "Hello" } }] }, { choices: [{ delta: {}, finish_reason: "stop" }] }),
)
const input = { model, prompt: "Say hello." }
const request = LLM.request(input)
const generated = await Effect.runPromise(LLM.generate(input).pipe(Effect.provide(layer)))
const generatedFromRequest = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(layer)))
expect(generated.text).toBe("Hello")
expect(generatedFromRequest.text).toBe(generated.text)
const streamed = await Effect.runPromise(LLM.stream(input).pipe(Stream.runCollect, Effect.provide(layer)))
const streamedFromRequest = await Effect.runPromise(
LLM.stream(request).pipe(Stream.runCollect, Effect.provide(layer)),
)
expect(Array.from(streamed).some(LLMEvent.is.textDelta)).toBe(true)
expect(streamedFromRequest).toEqual(streamed)
})
test("extracts output text from response events", () => {
expect(
LLMResponse.text({
+27 -6
View File
@@ -116,19 +116,32 @@ describe("AI promise client", () => {
const seen: Array<string> = []
const ai = AI.make({ layer: executor(seen) })
const text = await ai.llm.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })
const request = ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })
const text = await ai.llm.generate(request)
expect(text.text).toBe("Hello world")
expect((await ai.llm.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })).text).toBe(
"Hello world",
)
const image = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
expect(image.image).toBeInstanceOf(Media.Asset)
expect(image.image.mediaType).toBe("image/png")
expect(await ai.run(image.image.bytes())).toEqual(Uint8Array.from([1, 2, 3]))
const requested = await ai.image.generate(
ai.image.request({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" }),
)
expect(requested.image.mediaType).toBe("image/png")
const deltas: Array<string> = []
for await (const event of ai.llm.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) {
for await (const event of ai.llm.stream(request)) {
if (LLMEvent.is.textDelta(event)) deltas.push(event.text)
}
expect(deltas).toEqual(["Hello", " world"])
const directDeltas: Array<string> = []
for await (const event of ai.llm.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) {
if (LLMEvent.is.textDelta(event)) directDeltas.push(event.text)
}
expect(directDeltas).toEqual(deltas)
const imageEvents: Array<string> = []
for await (const event of ai.image.stream({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })) {
@@ -137,8 +150,11 @@ describe("AI promise client", () => {
expect(imageEvents).toEqual(["image-partial", "image", "finish"])
expect(seen).toEqual([
"https://openai.test/v1/chat/completions",
"https://openai.test/v1/chat/completions",
"https://openai.test/v1/images/generations",
"https://openai.test/v1/images/generations",
"https://openai.test/v1/chat/completions",
"https://openai.test/v1/chat/completions",
"https://openai.test/v1/images/generations",
])
@@ -260,22 +276,27 @@ describe("AI promise client", () => {
const ai = AI.make({ layer: executor([]) })
const failure = await ai.llm
.generate({ model: openai.responses("gpt-5"), prompt: "Hello" })
.generate(ai.llm.request({ model: openai.responses("gpt-5"), prompt: "Hello" }))
.then(() => undefined)
.catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AIError)
expect(failure instanceof AIError && failure.reason.http?.status).toBe(404)
const invalid = await ai.llm
// @ts-expect-error Invalid input must reject with AIError, not throw synchronously.
const invalidLLM = await ai.llm
// @ts-expect-error Invalid input must reject with AIError instead of throwing synchronously.
.generate({ model: openai.responses("gpt-5"), messages: [{ role: "bogus" }] })
.catch((error: unknown) => error)
expect(invalidLLM instanceof AIError && invalidLLM.reason._tag).toBe("InvalidRequest")
const invalid = await ai.image
.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse", n: 1.5 })
.catch((error: unknown) => error)
expect(invalid instanceof AIError && invalid.reason._tag).toBe("InvalidRequest")
const controller = new AbortController()
controller.abort()
const aborted = await ai.llm
.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }, { signal: controller.signal })
.generate(ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }), { signal: controller.signal })
.then(() => "completed")
.catch(() => "aborted")
expect(aborted).toBe("aborted")
+27
View File
@@ -216,6 +216,33 @@ it.effect("Alibaba keeps native reasoning controls and future efforts on their s
}),
)
it.effect("Alibaba fits explicit thinking budgets to half the output limit", () =>
Effect.gen(function* () {
const provider = Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" })
const chat = (maxTokens?: number) =>
compileRequest(
LLM.request({
model: provider.chat("qwen3.7-plus"),
prompt: "hi",
...(maxTokens === undefined ? {} : { generation: { maxTokens } }),
providerOptions: { enableThinking: true, thinkingBudget: 131_071 },
}),
).pipe(Effect.map((prepared) => prepared.body.thinking_budget))
const messages = yield* compileRequest(
LLM.request({
model: provider.messages("qwen3.7-plus"),
prompt: "hi",
generation: { maxTokens: 32_000 },
providerOptions: { thinking: { type: "enabled", budgetTokens: 131_071 } },
}),
)
expect(yield* chat(32_000)).toBe(16_000)
expect(yield* chat()).toBe(131_071)
expect(messages.body.thinking).toEqual({ type: "enabled", budget_tokens: 16_000 })
}),
)
it.effect("Alibaba validates malformed options before execution", () =>
Effect.gen(function* () {
const provider = Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" })
@@ -148,11 +148,13 @@ describe("Anthropic Messages route", () => {
Effect.gen(function* () {
const enabled = yield* compileRequest(
LLMRequest.update(request, {
generation: { maxTokens: 4_096 },
providerOptions: { thinking: { type: "enabled", budgetTokens: 1_024 } },
}),
)
const legacy = yield* compileRequest(
LLMRequest.update(request, {
generation: { maxTokens: 4_096 },
providerOptions: { thinking: { type: "enabled", budget_tokens: 2_048 } },
}),
)
@@ -168,6 +170,22 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("fits the thinking budget to half the output limit", () =>
Effect.gen(function* () {
const thinking = (maxTokens: number) =>
compileRequest(
LLMRequest.update(request, {
generation: { maxTokens },
providerOptions: { thinking: { type: "enabled", budgetTokens: 31_999 } },
}),
).pipe(Effect.map((prepared) => prepared.body.thinking))
expect(yield* thinking(64_000)).toEqual({ type: "enabled", budget_tokens: 31_999 })
expect(yield* thinking(20_000)).toEqual({ type: "enabled", budget_tokens: 10_000 })
expect(yield* thinking(1_500)).toEqual({ type: "enabled", budget_tokens: 1_024 })
}),
)
it.effect("rejects enabled thinking without a budget", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -244,6 +244,29 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("fits a Claude thinking budget below maxTokens", () =>
Effect.gen(function* () {
const fields = (maxTokens: number, budgetTokens: number, topK?: number) =>
compileRequest(
LLMRequest.update(baseRequest, {
model: AmazonBedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0", {
baseURL: "https://bedrock-runtime.test",
apiKey: "test-bearer",
thinking: { type: "enabled", budgetTokens },
}),
generation: GenerationOptions.make({ maxTokens, topK }),
}),
).pipe(Effect.map((prepared) => prepared.body.additionalModelRequestFields))
expect(yield* fields(64_000, 31_999)).toEqual({ thinking: { type: "enabled", budget_tokens: 31_999 } })
expect(yield* fields(20_000, 31_999, 40)).toEqual({
top_k: 40,
thinking: { type: "enabled", budget_tokens: 10_000 },
})
expect(yield* fields(1_500, 31_999)).toEqual({ thinking: { type: "enabled", budget_tokens: 1_024 } })
}),
)
it.effect("omits additionalModelRequestFields when topK is unset", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(baseRequest)
+81 -10
View File
@@ -22,21 +22,22 @@ testEffect(
expect(body).toMatchObject({
model: "fixture",
stream: true,
store: false,
store: true,
instructions: "Keep the context",
parallel_tool_calls: true,
parallel_tool_calls: false,
prompt_cache_key: "session-key",
service_tier: "priority",
reasoning: { effort: "high", summary: "auto" },
context_management: [{ type: "compaction" }],
max_tool_calls: 1,
tool_choice: "required",
text: { verbosity: "high", format: { type: "json_object" } },
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
})
expect(body.tools).toHaveLength(1)
expect(body.tools[0].name).toBe("lookup")
expect(body.tool_choice).toBeUndefined()
expect(body.context_management).toBeUndefined()
expect(body.text).toBeUndefined()
expect(body.max_output_tokens).toBeUndefined()
expect(body.previous_response_id).toBeUndefined()
return respond(
@@ -57,7 +58,7 @@ testEffect(
)
}),
),
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
).effect("trigger keeps request controls, configured deployment, and supplied subscription headers", () =>
Effect.gen(function* () {
const calls: string[] = []
const input = LLM.request({
@@ -67,12 +68,14 @@ testEffect(
promptCacheKey: "session-key",
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
toolChoice: { type: "tool", name: "lookup" },
generation: { maxTokens: 1 },
providerOptions: {
store: true,
reasoningEffort: "high",
reasoningSummary: "auto",
contextManagement: [{ type: "compaction" }],
parallelToolCalls: false,
maxToolCalls: 1,
textVerbosity: "low",
},
http: {
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
@@ -82,8 +85,7 @@ testEffect(
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "session", ttl: "1h" },
store: true,
stream: false,
text: { format: { type: "json_object" } },
text: { verbosity: "high", format: { type: "json_object" } },
tool_choice: "required",
},
},
@@ -114,6 +116,75 @@ testEffect(
}),
)
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
expect(JSON.parse(text).text).toEqual({ verbosity: "low", format: { type: "json_object" } })
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect("keeps explicit verbosity on a trigger checkpoint for prompt cache reuse", () =>
LLMClient.compact(
LLM.request({
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"),
prompt: "Hello.",
providerOptions: { textVerbosity: "low" },
http: { body: { text: { format: { type: "json_object" } } } },
}),
trigger,
),
)
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(body.text).toEqual({ verbosity: "high", format: { type: "json_object" } })
expect(body.max_output_tokens).toBe(20_000)
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect("keeps the effective body-overlay verbosity and text formatting", () =>
LLMClient.compact(
LLM.request({
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"),
prompt: "Hello.",
generation: { maxTokens: 20_000 },
providerOptions: { textVerbosity: "low" },
http: { body: { text: { verbosity: "high", format: { type: "json_object" } } } },
}),
trigger,
),
)
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
expect(JSON.parse(text).max_output_tokens).toBe(128)
return respond(JSON.stringify({ error: { message: "max_output_tokens must be at least 20000" } }), {
status: 400,
headers: { "content-type": "application/json" },
})
}),
),
).effect("passes configured output limits through and leaves rejection to the provider", () =>
Effect.gen(function* () {
const error = yield* LLMClient.compact(
LLM.request({
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"),
prompt: "Hello.",
generation: { maxTokens: 128 },
}),
trigger,
).pipe(Effect.flip)
expect(error.message).toContain("at least 20000")
}),
)
const idless = { type: "compaction", encrypted_content: "opaque" }
testEffect(
fixedResponse(
@@ -184,7 +255,7 @@ testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: ch
expect(error.reason._tag).toBe("InvalidProviderOutput")
}),
)
for (const body of [{ input: [] }, { previous_response_id: "stale" }]) {
for (const body of [{ input: [] }, { previous_response_id: "stale" }, { stream: false }]) {
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
() =>
@@ -110,11 +110,16 @@ for (const model of [
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
expect(JSON.parse(text)).toEqual({ model: "fixture", input: [item], instructions: "Keep the context" })
expect(JSON.parse(text)).toEqual({
model: "fixture",
input: [item],
instructions: "Keep the context",
include: ["reasoning.encrypted_content"],
})
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect(`${model.provider} compacts provider-specific history without lowering generation settings`, () =>
).effect(`${model.provider} validates tools but ignores unrelated unsupported generation settings`, () =>
Effect.gen(function* () {
const request = LLM.request({
model,
@@ -151,6 +156,11 @@ for (const model of [
] as const) {
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
expect(error.reason._tag).toBe(tag)
if (candidate.tools.length > 0) {
const compactError = yield* LLMClient.compact(candidate).pipe(Effect.flip)
expect(compactError.reason._tag).toBe("InvalidRequest")
continue
}
const response = yield* LLMClient.compact(candidate)
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
}
@@ -255,6 +265,13 @@ for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key:
model: "fixture",
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }],
service_tier: overlay?.service_tier ?? "flex",
reasoning: { effort: "low" },
text: { verbosity: "low", format: { type: "json_object" } },
include: ["reasoning.encrypted_content"],
parallel_tool_calls: false,
tools: [
{ type: "function", name: "lookup", description: "Lookup", parameters: { type: "object" }, strict: false },
],
prompt_cache_key: overlay?.prompt_cache_key ?? "affinity",
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
@@ -268,12 +285,20 @@ for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key:
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
prompt: "hello",
promptCacheKey: "affinity",
providerOptions: { serviceTier: "flex" },
providerOptions: {
serviceTier: "flex",
reasoningEffort: "low",
textVerbosity: "low",
include: ["reasoning.encrypted_content"],
parallelToolCalls: false,
},
generation: { maxTokens: 100 },
tools: [{ name: "lookup", description: "Lookup", inputSchema: {} }],
http: {
body: {
stream: true,
store: false,
text: { format: { type: "json_object" } },
prompt_cache_retention: "24h",
prompt_cache_options: { mode: "explicit", ttl: "30m" },
...overlay,
@@ -396,6 +421,8 @@ for (const model of [
model: model.id,
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "original" }] }],
instructions: "system",
include: ["reasoning.encrypted_content"],
...(model.id === "gpt-5.3-codex" ? { reasoning: { effort: "medium", summary: "auto" } } : {}),
})
return respond(
JSON.stringify({
@@ -407,7 +434,10 @@ for (const model of [
)
}
expect(new URL(request.url).pathname.endsWith("/responses")).toBe(true)
expect(body.input).toEqual([...output, { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }])
expect(body.input).toEqual([
...output,
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
])
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [] } }), {
headers: { "content-type": "text/event-stream" },
})
+17
View File
@@ -90,6 +90,23 @@ describe("Gemini route", () => {
}),
)
it.effect("fits the thinking budget to half the output limit", () =>
Effect.gen(function* () {
const thinkingBudget = (budget: number, maxTokens = 32_000) =>
compileRequest(
LLMRequest.update(request, {
generation: { maxTokens },
providerOptions: { thinkingConfig: { thinkingBudget: budget } },
}),
).pipe(Effect.map((prepared) => prepared.body.generationConfig?.thinkingConfig?.thinkingBudget))
expect(yield* thinkingBudget(32_768)).toBe(16_000)
expect(yield* thinkingBudget(8_000)).toBe(8_000)
expect(yield* thinkingBudget(-1)).toBe(-1)
expect(yield* thinkingBudget(8_192, 1_000)).toBe(512)
}),
)
it.effect("forwards standard Gemini generation options", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -1945,7 +1945,7 @@ describe("OpenAI Responses route", () => {
expect(prepared.body.prompt_cache_key).toBe("session_123")
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
expect(prepared.body.text).toEqual({ verbosity: "low" })
expect(prepared.body.text).toBeUndefined()
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
expect(prepared.body.safety_identifier).toBe("user_123")
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
@@ -152,6 +152,27 @@ describe("OpenRouter", () => {
}),
)
it.effect("fits the reasoning budget to half the output limit", () =>
Effect.gen(function* () {
const reasoning = (maxTokens: number | undefined, value: Record<string, unknown>) =>
compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3.8-flash"),
cache: "none",
prompt: "Hello",
...(maxTokens === undefined ? {} : { generation: { maxTokens } }),
providerOptions: { reasoning: value },
}),
).pipe(Effect.map((prepared) => prepared.body.reasoning))
expect(yield* reasoning(32_000, { max_tokens: 131_071 })).toEqual({ max_tokens: 16_000 })
expect(yield* reasoning(131_072, { max_tokens: 65_536 })).toEqual({ max_tokens: 65_536 })
expect(yield* reasoning(1_500, { max_tokens: 65_536 })).toEqual({ max_tokens: 1_024 })
expect(yield* reasoning(undefined, { max_tokens: 131_071 })).toEqual({ max_tokens: 131_071 })
expect(yield* reasoning(32_000, { effort: "high" })).toEqual({ effort: "high" })
}),
)
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+3 -1
View File
@@ -1,5 +1,5 @@
import type { Stream } from "effect"
import { Speech, type SpeechEvent } from "../src/index.js"
import { Speech, SpeechModel, type SpeechEvent, type SpeechOptions } from "../src/index.js"
import { ElevenLabs, OpenAI, Runway } from "../src/providers.js"
type StreamItem<T> = T extends Stream.Stream<infer A, infer _E, infer _R> ? A : never
@@ -7,6 +7,8 @@ type Equal<A, B> = [A, B] extends [B, A] ? true : false
type Assert<T extends true> = T
const elevenlabs = ElevenLabs.configure({ apiKey: "test" }).speech("eleven_flash_v2_5")
const widenSpeech = <Options extends SpeechOptions>(model: SpeechModel<Options>): SpeechModel => model
void widenSpeech
Speech.generate({
model: elevenlabs,
+11 -1
View File
@@ -1,5 +1,11 @@
import type { Stream } from "effect"
import { Media, Transcription, type TranscriptionEvent } from "../src/index.js"
import {
Media,
Transcription,
TranscriptionModel,
type TranscriptionEvent,
type TranscriptionOptions,
} from "../src/index.js"
import { AssemblyAI, Deepgram, OpenAI } from "../src/providers.js"
type StreamItem<T> = T extends Stream.Stream<infer A, infer _E, infer _R> ? A : never
@@ -8,6 +14,10 @@ type Assert<T extends true> = T
const audio = Media.url("https://example.com/call.mp3")
const deepgram = Deepgram.configure({ apiKey: "test" }).transcription("nova-3")
const widenTranscription = <Options extends TranscriptionOptions>(
model: TranscriptionModel<Options>,
): TranscriptionModel => model
void widenTranscription
Transcription.generate({
model: deepgram,
+3 -3
View File
@@ -9,7 +9,6 @@ import {
type VideoModelOptions,
type VideoOptions,
type VideoRequestFor,
type VideoRoute,
} from "../src/index.js"
import type { Service } from "../src/video-client.js"
import { Anthropic, Fal, Google, OpenAI, Runway, XAI } from "../src/providers.js"
@@ -23,8 +22,7 @@ type VeoLikeOptions = {
readonly personGeneration?: "allow_all" | "allow_adult"
} & Record<string, unknown>
declare const route: VideoRoute<VeoLikeOptions>
const veo = VideoModel.make<VeoLikeOptions>({ id: "veo", provider: "google", route })
declare const veo: VideoModel<VeoLikeOptions>
// @ts-expect-error Extracted model options retain known provider fields.
const invalidVeoOptions: VideoModelOptions<typeof veo> = { personGeneration: "everyone" }
void invalidVeoOptions
@@ -99,6 +97,8 @@ Video.generate({ model: google, prompt: "A kitten", durationSeconds: "8s" })
Video.generate({ model: google, prompt: "A kitten", options: { personGeneration: "allow_all" } })
declare const generic: VideoModel<VideoOptions>
const widenVideo = <Options extends VideoOptions>(model: VideoModel<Options>): VideoModel => model
void widenVideo
Video.generate({ model: generic, prompt: "A kitten", providerOptions: { arbitrary: true } })
const request = Video.request({ model: veo, prompt: "A kitten", providerOptions: { personGeneration: "allow_all" } })
@@ -2,7 +2,7 @@ import { DialogProvider } from "@opencode/ui/context/dialog"
import { Browser } from "@opencode/plugin-browser/rpc"
import { For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { render } from "solid-js/web"
import { Portal, render } from "solid-js/web"
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
import type { createSessionBrowser } from "../src/session/browser/model"
@@ -27,7 +27,12 @@ export function mountBrowserPane() {
loadErrors: {} as Record<string, string | undefined>,
error: undefined as string | undefined,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
covered: false,
captures: 0,
holdCapture: false,
})
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
const held: (() => void)[] = []
const tabs = ["Alpha", "Beta"].map((name) => ({
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
title: name,
@@ -44,6 +49,17 @@ export function mountBrowserPane() {
{
setLayout: (layout) => setStore("layouts", tab.title, layout),
command: async () => undefined,
capture: async () => {
setStore("captures", (count) => count + 1)
if (store.holdCapture) await new Promise<void>((resolve) => held.push(resolve))
const canvas = new OffscreenCanvas(4, 4)
const paint = canvas.getContext("2d")
if (paint) {
paint.fillStyle = "#3b82f6"
paint.fillRect(0, 0, 4, 4)
}
return canvas.convertToBlob()
},
close: () => undefined,
},
]),
@@ -118,12 +134,34 @@ export function mountBrowserPane() {
Complete navigation
</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
<p>Captures: {store.captures}</p>
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
<Show when={store.mounted}>
<SessionBrowserPane browser={browser} visible={store.visible} />
</Show>
</div>
<Show when={store.covered}>
{/* Floating content portals into <body> like a menu or hover card over the page. */}
<Portal mount={document.body}>
<div
data-popper-positioner
data-testid="fixture-popover"
style={{
position: "fixed",
top: "0",
left: "0",
width: "320px",
height: "480px",
"z-index": "1001",
"pointer-events": "none",
}}
/>
</Portal>
</Show>
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
<For each={tabs}>
@@ -58,6 +58,27 @@ story("hides the native view immediately while the pane stays mounted", async ({
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("keeps a still of the page under floating content that covers it", async ({ page }, testInfo) => {
const root = page.getByTestId("browser-pane-fixture")
const still = root.locator("#browser-panel img")
await root.getByRole("button", { name: "Hold capture", exact: true }).click()
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
// The native page stays up until its still is ready, so the pane never shows blank.
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(still).toHaveCount(0)
await root.getByRole("button", { name: "Release capture", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(still).toBeVisible()
await page.screenshot({ path: testInfo.outputPath("covered.png") })
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(still).toHaveCount(0)
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
+9
View File
@@ -1439,6 +1439,15 @@ export const dict = {
"settings.providers.section.connected": "Connected providers",
"settings.providers.connected.empty": "No connected providers",
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
"settings.providers.account.manage": "Manage {{provider}} accounts",
"settings.providers.account.group": "Accounts",
"settings.providers.account.add": "Add account",
"settings.providers.account.remove": "Remove account…",
"settings.providers.account.active": "Active",
"settings.providers.account.switched.title": "{{provider}} account switched",
"settings.providers.account.switched.description": "Now using {{account}}.",
"settings.providers.account.removed.title": "{{account}} removed",
"settings.providers.account.removed.description": "{{provider}} will no longer use this account.",
"settings.providers.console.available.one": "{{count}} provider available",
"settings.providers.console.available.other": "{{count}} providers available",
"settings.providers.section.popular": "Popular providers",
@@ -25,6 +25,8 @@ export type BrowserPaneEvent =
export type BrowserPaneRegistration = {
setLayout(layout?: BrowserPaneLayout): void
command(command: BrowserPaneCommand): Promise<void>
/** Captures the shown page, or resolves null when nothing is on screen. */
capture(tabID: Browser.TabID): Promise<Blob | null>
close(): void
}
@@ -43,6 +43,9 @@ function fixture() {
async command(command) {
call.commands.push(command)
},
async capture() {
return null
},
close() {
call.closed = true
},
+63 -2
View File
@@ -11,6 +11,7 @@ import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useCommand } from "@/shell/commands/command"
import type { Browser } from "@opencode/plugin-browser/rpc"
import type { createSessionBrowser } from "./model"
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
@@ -30,6 +31,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
// A still of the page shown in the DOM while floating content covers the hidden native view.
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
@@ -37,6 +40,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
let frame: number | undefined
let layout: string | undefined
let until = 0
let capturing: Browser.TabID | undefined
let release: ReturnType<typeof setTimeout> | undefined
const canvas = document.createElement("canvas")
canvas.width = canvas.height = 1
const paint = canvas.getContext("2d", { willReadFrequently: true })
@@ -69,6 +74,45 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const r = el.getBoundingClientRect()
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
})
const replaceSnapshot = (next?: { tabID: Browser.TabID; url: string }) => {
if (store.snapshot?.url) URL.revokeObjectURL(store.snapshot.url)
setStore("snapshot", next)
}
// Keep the page on screen as a still under the floating content. The native view
// stays visible until the still has decoded, so the pane never flashes blank.
const freeze = (tabID: Browser.TabID) => {
clearTimeout(release)
release = undefined
if (store.snapshot?.tabID === tabID || capturing === tabID) return
capturing = tabID
void (registration()?.capture(tabID) ?? Promise.resolve(null))
.catch(() => null)
.then(async (blob) => {
const url = blob ? URL.createObjectURL(blob) : ""
if (url) {
const image = new Image()
image.src = url
await image.decode().catch(() => undefined)
}
if (capturing !== tabID) {
if (url) URL.revokeObjectURL(url)
return
}
capturing = undefined
// A failed capture still hides the page; the pane shows its background as before.
replaceSnapshot({ tabID, url })
schedule()
})
}
const thaw = () => {
capturing = undefined
if (!store.snapshot || release !== undefined) return
// Keep the still under the native view until the view has painted again.
release = setTimeout(() => {
release = undefined
replaceSnapshot()
}, 150)
}
const measure = () => {
if (!surface) return
const tab = state()
@@ -84,7 +128,11 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const bottom = Math.round(rect.bottom * zoom)
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
const cover = covered(rect)
if (shown && cover) freeze(tab.id)
if (!cover) thaw()
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -186,6 +234,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
onCleanup(() => {
if (frame !== undefined) cancelAnimationFrame(frame)
clearTimeout(release)
capturing = undefined
replaceSnapshot()
})
return (
@@ -296,7 +347,17 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
{error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<div ref={surface} class="relative min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={store.snapshot?.tabID === state()?.id && !empty() && !failed() && store.snapshot?.url}>
{(url) => (
<img
src={url()}
alt=""
draggable={false}
class="absolute inset-0 size-full pointer-events-none select-none"
/>
)}
</Show>
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
@@ -0,0 +1,34 @@
import { describe, expect, test } from "bun:test"
import type { IntegrationInfo } from "@opencode/client/promise"
import { activeProviderAccount, providerAccounts } from "./accounts"
const integration = (connections: IntegrationInfo["connections"]): IntegrationInfo => ({
id: "openai",
name: "OpenAI",
methods: [],
connections,
})
describe("provider accounts", () => {
test("preserves the server's active-first credential order", () => {
const value = integration([
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
{ type: "env", name: "OPENAI_API_KEY" },
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
])
expect(providerAccounts(value)).toEqual([
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
])
expect(activeProviderAccount(value)).toEqual({ type: "credential", id: "cred_work", label: "Work", method: "key" })
})
test("returns no active account for environment-only integrations", () => {
const value = integration([{ type: "env", name: "OPENAI_API_KEY" }])
expect(providerAccounts(value)).toEqual([])
expect(activeProviderAccount(value)).toBeUndefined()
expect(providerAccounts(undefined)).toEqual([])
})
})
@@ -0,0 +1,11 @@
import type { ConnectionInfo, IntegrationInfo } from "@opencode/client/promise"
export type ProviderAccount = Extract<ConnectionInfo, { type: "credential" }>
export function providerAccounts(integration: IntegrationInfo | undefined) {
return integration?.connections.filter((connection): connection is ProviderAccount => connection.type === "credential") ?? []
}
export function activeProviderAccount(integration: IntegrationInfo | undefined) {
return providerAccounts(integration)[0]
}
+162 -19
View File
@@ -2,6 +2,7 @@ import { Button } from "@opencode/ui/button"
import { Badge } from "@opencode/ui/badge"
import { useDialog } from "@opencode/ui/context/dialog"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { OpenCodeLogo } from "@/providers/opencode-logo"
import { showToast } from "@/shell/notifications/toast"
import { popularProviders, useProviders } from "@/providers/catalog/providers"
@@ -16,6 +17,7 @@ import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/cont
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
import { ProviderModelIcon } from "@/providers/models/provider-group"
import { SettingsList } from "@/settings/list"
import { activeProviderAccount, providerAccounts, type ProviderAccount } from "./accounts"
import "@/settings/settings.css"
type ProviderSource = "env" | "api" | "account" | "config" | "custom"
@@ -47,6 +49,7 @@ export const SettingsProviders: Component<{
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
consoleExpanded: false,
connecting: false,
credentialID: undefined as string | undefined,
})
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
setState("disconnecting", (current) => ({
@@ -190,6 +193,8 @@ export const SettingsProviders: Component<{
return currentSource !== "env" && currentSource !== "config"
}
const canManageAccounts = (item: ProviderItem) => providerAccounts(integration(item)).length > 0
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const disconnect = async (item: ProviderItem, name: string) => {
@@ -230,6 +235,132 @@ export const SettingsProviders: Component<{
})
}
const refreshAccounts = async () => {
const location = props.directory ? { directory: props.directory } : undefined
data.location.integration.invalidate(location)
data.location.provider.invalidate(location)
data.location.model.invalidate(location)
await Promise.all([
data.location.integration.sync(location),
data.location.provider.sync(location),
data.location.model.sync(location),
])
}
const accountError = (error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
showToast({ title: language.t("common.requestFailed"), description: message })
}
const activate = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
if (activeProviderAccount(integration(provider))?.id === account.id) return
setState("credentialID", account.id)
await serverSdk.api.credential
.activate({ credentialID: account.id })
.then(refreshAccounts)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t("settings.providers.account.switched.title", { provider: providerName }),
description: language.t("settings.providers.account.switched.description", { account: account.label }),
}),
)
.catch(accountError)
.finally(() => setState("credentialID", undefined))
}
const remove = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
const final = providerAccounts(integration(provider)).length === 1
setState("credentialID", account.id)
await serverSdk.api.credential
.remove({ credentialID: account.id })
.then(refreshAccounts)
.then(() =>
showToast({
variant: "success",
icon: "circle-check",
title: language.t(
final ? "provider.disconnect.toast.disconnected.title" : "settings.providers.account.removed.title",
final ? { provider: providerName } : { account: account.label },
),
description: language.t(
final
? "provider.disconnect.toast.disconnected.description"
: "settings.providers.account.removed.description",
{ provider: providerName },
),
}),
)
.catch(accountError)
.finally(() => setState("credentialID", undefined))
}
function AccountMenu(menuProps: { provider: ProviderItem; name?: string }) {
const accounts = () => providerAccounts(integration(menuProps.provider))
const active = () => activeProviderAccount(integration(menuProps.provider))
const name = () => menuProps.name ?? menuProps.provider.name
return (
<Menu placement="bottom-end" gutter={6}>
<Menu.Trigger
as={Button}
size="normal"
variant="ghost-muted"
class="settings-provider-account-trigger"
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
>
<span>{active()?.label}</span>
<Icon name="chevron-down" size="small" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
<Menu.Group>
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
<Menu.RadioGroup
class="settings-provider-account-list"
value={active()?.id}
onChange={(credentialID) => {
const account = accounts().find((item) => item.id === credentialID)
if (account) void activate(menuProps.provider, name(), account)
}}
>
<For each={accounts()}>
{(account) => (
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
<span class="settings-provider-account-label">{account.label}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</Menu.Group>
<Menu.Separator />
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
{language.t("settings.providers.account.add")}
</Menu.Item>
<Menu.Sub placement="left-start">
<Menu.SubTrigger disabled={state.credentialID !== undefined || accounts().length === 0}>
{language.t("settings.providers.account.remove")}
</Menu.SubTrigger>
<Menu.SubContent class="settings-provider-account-submenu">
<For each={accounts()}>
{(account) => (
<Menu.Item
badge={account.id === active()?.id ? language.t("settings.providers.account.active") : undefined}
onSelect={() => void remove(menuProps.provider, name(), account)}
>
<span class="settings-provider-account-label">{account.label}</span>
</Menu.Item>
)}
</For>
</Menu.SubContent>
</Menu.Sub>
</Menu.Content>
</Menu.Portal>
</Menu>
)
}
return (
<>
<div class="settings-tab-header">
@@ -268,22 +399,27 @@ export const SettingsProviders: Component<{
</div>
</div>
<Show
when={canDisconnect(item)}
when={canManageAccounts(item)}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
<Show
when={canDisconnect(item)}
fallback={
<span class="settings-provider-env-hint">
{language.t("settings.providers.connected.environmentDescription")}
</span>
}
>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item, item.name)}
>
{language.t("common.disconnect")}
</Button>
</Show>
}
>
<Button
size="normal"
variant="ghost-muted"
onClick={() =>
void disconnect(item, item.name)
}
>
{language.t("common.disconnect")}
</Button>
<AccountMenu provider={item} />
</Show>
</div>
}
@@ -326,13 +462,20 @@ export const SettingsProviders: Component<{
</Show>
</div>
</div>
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
<Show
when={canManageAccounts(item)}
fallback={
<Button
size="normal"
variant="ghost-muted"
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
>
{language.t("common.disconnect")}
</Button>
}
>
{language.t("common.disconnect")}
</Button>
<AccountMenu provider={item} name={language.t("provider.connect.opencode.name")} />
</Show>
</div>
<Show when={state.consoleExpanded}>
<div class="settings-provider-console-list">
+41
View File
@@ -887,6 +887,47 @@
opacity: 1;
}
.settings-provider-account-trigger {
min-width: 0;
max-width: min(240px, 45%);
}
.settings-provider-account-trigger > span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settings-provider-account-menu[data-component="menu-v2-content"] {
width: min(260px, calc(100vw - 32px));
overflow: visible;
}
.settings-provider-account-list {
max-height: min(240px, calc(var(--kb-popper-content-available-height) - 120px));
overflow-y: auto;
}
.settings-provider-account-submenu[data-component="menu-v2-content"] {
width: min(260px, calc(100vw - 32px));
max-height: min(360px, var(--kb-popper-content-available-height));
overflow-y: auto;
}
.settings-provider-account-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@container settings-panel (max-width: 520px) {
.settings-provider-account-trigger {
max-width: 100%;
}
}
.settings-providers-view-all {
margin-top: 20px;
padding: 0;
+1 -1
View File
@@ -21,7 +21,7 @@
"build:node": "bun run script/build-node.ts",
"dev": "bun run src/index.ts",
"test": "bun test --timeout 30000 --only-failures",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo -b"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
+2 -1
View File
@@ -7,5 +7,6 @@
"lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
"noUncheckedIndexedAccess": false
},
"exclude": ["dist", "dist-node"]
"exclude": ["dist", "dist-node"],
"references": [{ "path": "../core" }]
}
+61 -67
View File
@@ -444,7 +444,7 @@ export function make(options: ClientOptions) {
path: `/api/location`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -469,7 +469,7 @@ export function make(options: ClientOptions) {
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -481,7 +481,7 @@ export function make(options: ClientOptions) {
path: `/api/agent/${encodeURIComponent(input.agentID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -495,7 +495,7 @@ export function make(options: ClientOptions) {
path: `/api/plugin`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -508,7 +508,7 @@ export function make(options: ClientOptions) {
query: { location: input?.["location"] },
body: { target: input?.["target"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -521,7 +521,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { targets: input["targets"] },
successStatus: 204,
declaredStatuses: [400, 401, 403, 503],
declaredStatuses: [400, 401, 503],
empty: true,
},
requestOptions,
@@ -1109,7 +1109,7 @@ export function make(options: ClientOptions) {
path: `/api/model`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -1121,7 +1121,7 @@ export function make(options: ClientOptions) {
path: `/api/model/default`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -1149,7 +1149,7 @@ export function make(options: ClientOptions) {
path: `/api/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -1161,7 +1161,7 @@ export function make(options: ClientOptions) {
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404, 503],
declaredStatuses: [400, 401, 404, 503],
empty: false,
},
requestOptions,
@@ -1175,7 +1175,7 @@ export function make(options: ClientOptions) {
path: `/api/integration`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1187,7 +1187,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1201,7 +1201,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { url: input["url"] },
successStatus: 204,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1216,7 +1216,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { key: input["key"], answer: input["answer"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1231,7 +1231,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1243,7 +1243,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1256,7 +1256,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { code: input["code"] },
successStatus: 204,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1268,7 +1268,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1283,7 +1283,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { methodID: input["methodID"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1295,7 +1295,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1307,7 +1307,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1322,7 +1322,7 @@ export function make(options: ClientOptions) {
path: `/api/mcp`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1335,7 +1335,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { config: input["config"] },
successStatus: 204,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1347,7 +1347,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1359,7 +1359,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}/connect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1371,7 +1371,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}/disconnect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1384,7 +1384,7 @@ export function make(options: ClientOptions) {
path: `/api/mcp/resource`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1430,7 +1430,7 @@ export function make(options: ClientOptions) {
project: {
list: (requestOptions?: RequestOptions) =>
request<ProjectListOutput>(
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401, 403], empty: false },
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
@@ -1445,7 +1445,7 @@ export function make(options: ClientOptions) {
commands: input["commands"],
},
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1459,7 +1459,7 @@ export function make(options: ClientOptions) {
path: `/api/form`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1474,7 +1474,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1488,7 +1488,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1499,7 +1499,7 @@ export function make(options: ClientOptions) {
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1568,7 +1568,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/read/${encodePath(input.path)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
binary: true,
},
@@ -1581,7 +1581,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/list`,
query: { location: input?.["location"], path: input?.["path"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1593,7 +1593,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/find`,
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1606,7 +1606,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"], path: input["path"] },
body: input["payload"],
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
binaryBody: true,
},
@@ -1621,7 +1621,7 @@ export function make(options: ClientOptions) {
path: `/api/command`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1635,7 +1635,7 @@ export function make(options: ClientOptions) {
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1650,7 +1650,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { input: input["input"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 500],
declaredStatuses: [400, 401, 500],
empty: false,
},
requestOptions,
@@ -1671,7 +1671,7 @@ export function make(options: ClientOptions) {
path: `/api/pty`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1690,7 +1690,7 @@ export function make(options: ClientOptions) {
env: input?.["env"],
},
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1702,7 +1702,7 @@ export function make(options: ClientOptions) {
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1715,7 +1715,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { title: input["title"], size: input["size"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1727,7 +1727,7 @@ export function make(options: ClientOptions) {
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1881,7 +1881,7 @@ export function make(options: ClientOptions) {
path: `/api/shell`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1899,7 +1899,7 @@ export function make(options: ClientOptions) {
metadata: input["metadata"],
},
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -1911,7 +1911,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1923,7 +1923,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}/output`,
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 404],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1935,7 +1935,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -1949,7 +1949,7 @@ export function make(options: ClientOptions) {
path: `/api/reference`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -2019,7 +2019,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -2031,7 +2031,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/base`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -2043,7 +2043,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/status`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -2056,7 +2056,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/branch`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
@@ -2069,7 +2069,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/diff`,
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -2125,7 +2125,7 @@ export function make(options: ClientOptions) {
path: `/api/websearch/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -2138,7 +2138,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { query: input["query"], providerID: input["providerID"] },
successStatus: 200,
declaredStatuses: [400, 401, 403, 503],
declaredStatuses: [400, 401, 503],
empty: false,
},
requestOptions,
@@ -2152,20 +2152,14 @@ export function make(options: ClientOptions) {
path: `/api/config`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
shells: (requestOptions?: RequestOptions) =>
request<ConfigShellsOutput>(
{
method: "GET",
path: `/api/config/shell`,
successStatus: 200,
declaredStatuses: [400, 401, 403],
empty: false,
},
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
requestOptions,
),
update: (input: ConfigUpdateInput, requestOptions?: RequestOptions) =>
@@ -2175,7 +2169,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/config`,
body: { shell: input["shell"] },
successStatus: 204,
declaredStatuses: [400, 401, 403],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -2484,22 +2484,6 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type LocationDirectoryNotFoundError = {
readonly _tag: "LocationDirectoryNotFoundError"
readonly directory: string
readonly message: string
}
export const isLocationDirectoryNotFoundError = (value: unknown): value is LocationDirectoryNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LocationDirectoryNotFoundError"
export type LocationPermissionDeniedError = {
readonly _tag: "LocationPermissionDeniedError"
readonly directory: string
readonly message: string
}
export const isLocationPermissionDeniedError = (value: unknown): value is LocationPermissionDeniedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LocationPermissionDeniedError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
+70 -35
View File
@@ -85,8 +85,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators, including stepwise elisions/rest and `IteratorClose` on early completion
or binding/default failure.
- [ ] Object destructuring from primitives follows ToObject (`const { length } = "abc"`, `const {} = 1`); non-object
sources are rejected.
- [x] Object destructuring from primitives follows ToObject: `const { length } = "abc"` is `3`, `const { toFixed } = 1`
finds the built-in, `const {} = 1` is a no-op, and a rest element copies a string's indexes (`{ 1: "y", 2: "z" }`).
Only `null` and `undefined` sources throw (`Cannot destructure null as it is null.`).
- [x] Destructuring reads through the prototype chain like member access: `const { constructor } = error` and
`const { slice } = values` find the inherited built-in.
- [x] Any assignment target as a `for...in` head, like `for...of`: `for (x.y in obj)`, `for (a[i++] in obj)`, and
@@ -130,7 +131,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
does not consume are ignored, like JS, and consumed arguments coerce, like JS (`"3.7".replace(/\d\.\d/,
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
because `includes` is called without a string `this`.
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
@@ -139,11 +140,25 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
arrow function.
- [x] Promise-returning string replacers are coerced synchronously to `"[object Promise]"`, like JavaScript; they are
not automatically awaited.
- [x] The optional `thisArg` of iteration methods is accepted and ignored: CodeMode functions have no `this`, so
ignoring it matches JS arrow-function semantics exactly.
- [ ] `this` in non-arrow CodeMode functions and callbacks.
- [x] `this` in non-arrow functions is the call's receiver: `obj.m()` and `obj["m"]()` see `obj`, a bare or detached
call (`f()`, `const m = obj.m; m()`, `(0, obj.m)()`) sees `undefined`, as in strict JS. Arrows read the enclosing
function's `this`. Program code has no receiver, so top-level `this` is `undefined`, as in a module.
- [x] `arguments` in non-arrow functions: an unmapped ordinary object with the call's arguments as indexed
properties and a hidden `length`; iterable, so spread, `for...of`, and `Array.from` work. It is not an Array
(`JSON.stringify` gives `{"0":1}`, `String` gives `[object Arguments]`). A parameter named `arguments` shadows
it; arrows read the enclosing function's; it is only created for functions whose body mentions it. `callee`
and `caller` are absent rather than poisoned.
- [ ] Array methods on `arguments` and other array-likes (`Array.prototype.slice.call(arguments, 1)`); use
`[...arguments]` or a rest parameter meanwhile.
- [x] `Function.prototype.call`, `apply`, and `bind` on program functions and built-ins:
`Array.prototype.push.call(arr, 1)`, `Math.max.apply(null, values)`, `fn.bind(obj, first)`. `apply` accepts an
array, an array-like object (its `length` clamped and capped like `Array.from`), or `null`/`undefined`. A
bound function is named `bound f`, has its remaining `length`, and is not constructible.
- [x] `JSON.parse` revivers and `JSON.stringify` function replacers see the holder object as `this`.
- [x] The optional `thisArg` of the Array, Uint8Array, and `Array.from` callback methods and of Map, Set,
URLSearchParams, and Headers `forEach` is the callback's `this`: `[1, 2].forEach(function () { this.n++ }, c)`
increments `c.n` twice. Arrows ignore it, as in JS; `reduce`/`reduceRight` take an initial value instead.
- [ ] User-defined constructor calls.
- [ ] `Function.prototype.call`, `apply`, and `bind` for CodeMode functions.
- [ ] Classes and private fields.
- [x] Functions are objects: they hold own properties (`fn.count = 1`), enumerate them, and expose read-only `name`
and `length`. Names follow JavaScript's NamedEvaluation: declarations, named expressions, bindings,
@@ -178,7 +193,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
yielded promises; mixed async request queues; sync and async `yield*` forwarding; malformed methods/results;
and declaration, expression, and object-method forms with closure and parameter behavior. The adapted suite
deliberately skips Test262 variants whose observation mechanism requires unsupported getter definitions,
proxies, prototype inspection or mutation, non-arrow `this`, classes, or arbitrary symbols. It also skips tests
proxies, prototype inspection or mutation, classes, or arbitrary symbols. It also skips tests
asserting exact promise reaction-turn counts beyond the observable ordering guarantee documented below. These
are interpreter-surface boundaries, not claims that the corresponding full Test262 families pass unchanged.
@@ -211,8 +226,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Coercion helpers and template interpolation accept functions and namespaces: `String(fn)` and `${fn}` give
`"[object Function]"` rather than the source text, `isNaN(fn)` is `true`.
- [x] `==` and `!=` follow IsLooselyEqual: objects (including functions and tool references) compare by identity, a
nullish operand never coerces the other side, and a data object facing a primitive coerces through its built-in
primitive form (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
nullish operand never coerces the other side, and a data object facing a primitive converts through its own
`valueOf`/`toString` (default hint) (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
`switch` matches cases with `===`, so `switch (fn) { case fn: }` selects, and `Object.is` compares any two
values. Operators inspect only their direct operands, so `rows == null` on a large array costs the same as
`rows === null`, and an object merely holding a function inside (`[fn] + ""`, `-[fn]`) coerces like any other
@@ -220,13 +235,26 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [ ] Coercing a function, promise, generator, or tool reference itself: `fn + ""`, `-fn`, `fn++`, and `fn == 1`
throw `TypeError: Binary operators require data values.` (or the unary/update form) where JavaScript would use
the source text or `NaN`.
- [ ] ToPrimitive on program objects: operators, `Number`/`String`, `Error(message)`, `parseInt` radix, multi-argument
`Date` construction and `Date.UTC`, and numeric built-in arguments (`Math.max`, `at`, `indexOf` start) should call
the object's own `valueOf`/`toString` in spec order and surface their throws. Today they use the built-in form
(`NaN`, `"[object Object]"`) and ignore own methods. Date setters and one-argument `Date` construction already
follow ToPrimitive.
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
string keys.
- [x] ToPrimitive on program objects: `+ - * / % **`, the relational and bitwise operators, unary `+ - ~`, `++`/`--`,
compound assignment, `${x}`, `Number`/`String`/`isNaN`/`isFinite`, `parseInt`/`parseFloat` (text and radix),
`Math.*` arguments, `Error(message)`, and `Array.prototype.join`/`toString` elements call the object's own
`valueOf`/`toString` in spec order (both operands left then right, `+` with the default hint) and surface their
throws: `{ valueOf() { return 7 } } * 2` is `14`, `` `${{ toString() { return "x" } }}` `` is `"x"`, and
`[1, 2]` with `arr.toString = () => "x"` makes `arr + ""` `"x"`. Dates keep their `Symbol.toPrimitive`
behavior (`date + 1` concatenates, `date - date` subtracts).
- [x] String and Number method arguments convert through ToPrimitive in spec order, receiver first: search strings,
separators, fills, and replacements with the string hint, indexes, counts, digits, and radixes with the number
hint (`"abc".indexOf({ toString() { return "b" } })` is `1`, `(255).toString({ valueOf() { return 16 } })` is
`"ff"`, `String.prototype.trim.call({ toString() { return " a " } })` is `"a"`). Only consumed positions
convert; a RegExp pattern is used as is, and `includes`/`startsWith`/`endsWith` reject one before converting.
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric arguments of the Array and
Uint8Array methods (`at`, `indexOf` start, `slice`) still use the built-in form (`NaN`, `"[object Object]"`) and
ignore own methods.
- [x] Property keys follow ToPropertyKey: `x[null]` and `x[true]` become string keys, and a data object key
converts through its own `toString`/`valueOf` (string hint) exactly once per access, in reads, writes,
compound assignment, `++`, `delete`, `in`, object literals, and destructuring:
`o[{ toString() { return "id" } }] += 1` updates `o.id`. A nullish base throws before the key converts, as
in JS. Opaque values (functions, promises, tool references) keep their built-in string form.
## Promises and tools
@@ -269,7 +297,7 @@ reject }` object.
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
and a JavaScript `this` receiver remain outside the supported object/function model.
remain outside the supported object model.
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last tool supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
@@ -287,8 +315,9 @@ reject }` object.
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
primitive wrapper objects (`Object(1)`) are rejected explicitly.
- [x] Computed property names and object spread. Any value works as a key (ToPropertyKey): strings, numbers, and the
two confined symbols as themselves, everything else as its string form (`o[null]` is `o["null"]`, `o[{}]` is
`o["[object Object]"]`), in reads, writes, literals, `in`, and destructuring.
two confined symbols as themselves, data objects through their own `toString` (`o[[1, 2]]` is `o["1,2"]`), and
everything else as its string form (`o[null]` is `o["null"]`), in reads, writes, literals, `in`, and
destructuring.
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
@@ -349,7 +378,6 @@ reject }` object.
shares one prototype, where JavaScript gives each collection its own; `Object.getPrototypeOf` shows the
difference.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`.
- [x] Canonical array/string index parsing: keys such as `"01"` are ordinary properties rather than aliases of index
`1`.
@@ -362,8 +390,9 @@ reject }` object.
`flat(1.9)`, `with(1.5, v)`, `Math.max("3", "2")`, `parseInt("11", "2")`, `(1.5).toFixed("2")`,
`String.fromCharCode("65")`, and the Uint8Array equivalents. `join(sep)` and `JSON.parse(text)` apply ToString
(`join(null)` is `"1null2"`, `JSON.parse(123)` is `123`). `Array.from({ length: "2" })` applies ToLength; a
promise source still throws with an `await` hint rather than JS's silent `[]`. A program object's own
`valueOf`/`toString` is not consulted yet (see ToPrimitive above).
promise source still throws with an `await` hint rather than JS's silent `[]`. `join`, `Math.*`, `parseInt`,
and the String and Number methods consult a program object's own `valueOf`/`toString`; the array methods do not
yet (see ToPrimitive above).
## Strings
@@ -380,14 +409,15 @@ reject }` object.
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
`repeat` still requires a finite non-negative count.
expressions with a native-style `TypeError`. Data objects convert through their own `toString`/`valueOf` (see
ToPrimitive above). Opaque runtime references still reject as data errors, and `repeat` still requires a finite
non-negative count.
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern.
- [x] `String.raw`, on a template object or any `{ raw }` object; raw strings and substitutions coerce through their own
`toString`.
- [x] `match`, `matchAll`, `search`, and `split` read any non-RegExp argument as a pattern string, as `new RegExp(arg)`
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, and `undefined` is the empty pattern.
Objects use their built-in string form until ToPrimitive lands.
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, `undefined` is the empty pattern, and
an object supplies its own `toString`.
## Numbers and Math
@@ -419,9 +449,9 @@ reject }` object.
- [x] `JSON.parse` and `JSON.stringify` for supported data objects.
- [x] Numeric/string indentation for `JSON.stringify`.
- [x] `JSON.parse` reviver callbacks, including postorder traversal, deletion through `undefined`, and root replacement.
Revivers receive `(key, value)` but no `this` holder because CodeMode functions intentionally have no `this`.
Revivers receive `(key, value)` with the holder as `this`.
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
the root, with the holder as `this`. Array replacers preserve requested property order, deduplicate names, coerce
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`. An Error prints as
`Error.prototype.toString` would show it (`Error: boom`), wherever it appears in the logged value.
@@ -441,15 +471,15 @@ reject }` object.
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
`TimeClip` behavior.
`TimeClip` behavior. On an invalid Date every setter but `setTime` and `set(UTC)FullYear` answers `NaN` without
writing, so a time set inside an argument's `valueOf` survives.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] `toDateString` and `toTimeString` in the host's local timezone.
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [x] Date setters and one-argument construction coerce object arguments through their own `valueOf`/`toString` and
surface their throws.
- [ ] Multi-argument construction and `Date.UTC` coerce object arguments the same way (see ToPrimitive above).
- [x] Date setters, construction, and `Date.UTC` coerce object arguments through their own `valueOf`/`toString` in
argument order and surface their throws; only the first seven components are converted.
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
representation for the string primitive.
- [x] Native `RangeError` branding for invalid `toISOString()` calls.
@@ -496,6 +526,11 @@ reject }` object.
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
`isSupersetOf`, and `isDisjointFrom`, including supported Set-like operands.
- [x] `WeakMap` (`get`, `set`, `has`, `delete`, `getOrInsert`, `getOrInsertComputed`) and `WeakSet` (`add`, `has`,
`delete`), constructed from iterables. Keys must be program objects: a primitive or tool reference throws
`Invalid value used as weak map key`, while `has`/`delete`/`get` with one answer `false`/`undefined`. Entries are
held by a host weak collection, so nothing is retained past the key's own lifetime. As in JS they have no `size`,
iteration, or `clear`, `structuredClone` rejects them, and they serialize to `{}` at host boundaries.
## URL and URI helpers
@@ -596,8 +631,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
carries a 1-based line and column in the submitted source instead.
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
- [ ] The derived error constructors inheriting from `Error`: `Object.getPrototypeOf(TypeError)` is
`Function.prototype` here, while `TypeError.prototype` does inherit from `Error.prototype`.
- [x] Derived error constructors extend `Error` itself: `Object.getPrototypeOf(TypeError) === Error`, so
`TypeError.isError` is inherited, and `TypeError.prototype` inherits from `Error.prototype`.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
`catch`.
+2 -2
View File
@@ -24,7 +24,7 @@ import { typeofValue } from "./interpreter/references.js"
export type Json = Schema.Json
type Replacer<R> = (args: Array<Value>) => Effect.Effect<Value, unknown, R>
type Replacer<R> = (args: Array<Value>, holder: Obj) => Effect.Effect<Value, unknown, R>
/**
* What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and
@@ -60,7 +60,7 @@ const walk = <R>(
const settled = raw instanceof PromiseObj ? yield* ctx.await(raw) : raw
const toJSON = settled instanceof Obj ? get(settled, "toJSON") : undefined
const own = toJSON instanceof Callable ? yield* ctx.call(toJSON, settled, [key]) : settled
const value = replacer === undefined ? own : yield* replacer([key, own])
const value = replacer === undefined ? own : yield* replacer([key, own], holder)
if (value === undefined || typeofValue(value) === "function") return undefined
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (value === null || typeof value === "string" || typeof value === "boolean") return value
+31 -10
View File
@@ -13,7 +13,7 @@ import {
type Cursor,
type Value,
} from "./objects.js"
import { typeofValue } from "./references.js"
import { isOpaque, typeofValue } from "./references.js"
/** IteratorClose: a consumer failure closes the iterator and wins over any close failure, except that a generator's
* return() is a return completion, so a failing close wins over it, as after `break`. */
@@ -31,16 +31,15 @@ export const preserveConsumerError = <A, R>(
})
})
export type Hint = "number" | "string" | "default"
/**
* ToPrimitive: calls `valueOf`/`toString` in hint order and returns the first primitive result. Dates treat the
* default hint as "string", like their `Symbol.toPrimitive`.
* default hint as "string", like their `Symbol.toPrimitive`. Opaque values (functions, promises, generators, tool
* references) pass through unchanged so callers reject or describe them in their built-in form.
*/
export const toPrimitive = <R>(
ctx: Interpreter<R>,
value: Value,
hint: "number" | "string" | "default",
): Effect.Effect<Value, unknown, R> => {
if (!(value instanceof Obj)) return Effect.succeed(value)
export const toPrimitive = <R>(ctx: Interpreter<R>, value: Value, hint: Hint): Effect.Effect<Value, unknown, R> => {
if (!(value instanceof Obj) || isOpaque(value)) return Effect.succeed(value)
const asString = hint === "string" || (hint === "default" && value instanceof DateObj)
const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"]
return Effect.gen(function* () {
@@ -67,6 +66,28 @@ export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: Value) =>
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
/**
* Runs a native body on its arguments after ToPrimitive, in order, with one hint for all positions or one per
* position. Primitive arguments skip the Effect entirely.
*/
export const withPrimitives = <R>(
ctx: Interpreter<R>,
hints: Hint | ReadonlyArray<Hint>,
values: Array<Value>,
body: (primitives: Array<Value>) => Value | Effect.Effect<Value, unknown, R>,
): Value | Effect.Effect<Value, unknown, R> => {
if (!values.some((value) => value instanceof Obj)) return body(values)
return Effect.flatMap(
Effect.forEach(values, (value, index) =>
toPrimitive(ctx, value, typeof hints === "string" ? hints : hints[index]!),
),
(primitives) => {
const result = body(primitives)
return Effect.isEffect(result) ? result : Effect.succeed(result)
},
)
}
// The single acceptance list for callbacks: collections, sort, string replacers,
// Array.from mappers, and promise reactions all admit exactly these callables.
// Admission means dispatchable, not necessarily invocable: new-requiring
@@ -78,7 +99,7 @@ export const applyCollectionCallback = <R>(
ctx: Interpreter<R>,
callback: Value,
name: string,
): ((args: Array<Value>) => Effect.Effect<Value, unknown, R>) => {
): ((args: Array<Value>, thisValue?: Value) => Effect.Effect<Value, unknown, R>) => {
if (!isSupportedCallback(callback)) {
if (typeofValue(callback) === "function") {
throw typeError(
@@ -87,5 +108,5 @@ export const applyCollectionCallback = <R>(
}
throw typeError(`${name} expects a function callback.`)
}
return (callbackArgs) => ctx.call(callback, undefined, callbackArgs)
return (callbackArgs, thisValue) => ctx.call(callback, thisValue, callbackArgs)
}
+9 -4
View File
@@ -20,6 +20,7 @@ import {
type Value,
} from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { toPrimitiveString } from "./callback.js"
import { formatValue } from "../stdlib/console.js"
export const normalizeError = (error: unknown): Diagnostic => {
@@ -139,14 +140,13 @@ const constructAggregateErrorValue = <R>(
proto: Obj,
): Effect.Effect<ErrorObj, unknown, R> =>
Effect.gen(function* () {
const message = args[1] === undefined ? "" : yield* toPrimitiveString(ctx, args[1])
const cursor = yield* ctx.iterate(args[0])
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
const errors: Array<Value> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
return createAggregateErrorValue(ctx, errors, args[1] === undefined ? "" : coerceToString(args[1]), proto)
}
if (step.done) return createAggregateErrorValue(ctx, errors, message, proto)
errors.push(step.value)
}
})
@@ -160,7 +160,9 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
const created =
type === "AggregateError"
? constructAggregateErrorValue(ctx, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
: Effect.map(args[0] === undefined ? Effect.undefined : toPrimitiveString(ctx, args[0]), (message) =>
createErrorValue(proto, message),
)
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
const options = args[type === "AggregateError" ? 2 : 1]
if (!(options instanceof Obj) || !has(options, "cause")) return created
@@ -180,6 +182,9 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
])
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
return ctor
}
// Derived constructors extend Error itself, so its statics are inherited. The globals table creates Error first.
ctor.proto = get(builtins.Error, "constructor") as Obj
return ctor
}
+34 -3
View File
@@ -1,8 +1,8 @@
import { Effect } from "effect"
import type { Value } from "./objects.js"
import { Arr, Callable, coerceToInteger, coerceToString, get, Obj, type Value } from "./objects.js"
import { arrayGlobal } from "../stdlib/array.js"
import { textDecoderGlobal, textEncoderGlobal, uint8ArrayGlobal } from "../stdlib/bytes.js"
import { mapGlobal, setGlobal } from "../stdlib/collections.js"
import { mapGlobal, setGlobal, weakMapGlobal, weakSetGlobal } from "../stdlib/collections.js"
import { consoleGlobal } from "../stdlib/console.js"
import { dateGlobal } from "../stdlib/date.js"
import { jsonGlobal } from "../stdlib/json.js"
@@ -19,8 +19,9 @@ import { base64Global, cryptoGlobal, structuredCloneGlobal } from "../stdlib/web
import { ToolReference } from "../tool-runtime.js"
import { errorGlobal } from "./errors.js"
import { errorTypes } from "./intrinsics.js"
import { constants, constructor, native } from "./native.js"
import { constants, constructor, methods, native, receiver } from "./native.js"
import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js"
import { checkArrayLength } from "./limits.js"
import { generatorGlobals } from "./generators.js"
import { promiseGlobal } from "./promises.js"
import type { Interpreter } from "./interpreter.js"
@@ -31,6 +32,24 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
Effect.sync(() => {
throw typeError("The Function constructor is not supported; write the function inline.")
})
const target = (thisValue: Value, method: string) => receiver(Callable, thisValue, `Function.prototype.${method}`)
methods(ctx.builtins, ctx.builtins.Function, [
["call", 1, (thisValue, args) => ctx.call(target(thisValue, "call"), args[0], args.slice(1))],
["apply", 2, (thisValue, args) => ctx.call(target(thisValue, "apply"), args[0], listFromArrayLike(args[1]))],
[
"bind",
1,
(thisValue, args) => {
const fn = target(thisValue, "bind")
const bound = args.slice(1)
return native<R>(ctx.builtins, {
name: `bound ${coerceToString(get(fn, "name"))}`,
length: Math.max(0, fn.length - bound.length),
call: (_, rest) => ctx.call(fn, args[0], [...bound, ...rest]),
})
},
],
])
return constructor<R>(ctx.builtins, ctx.builtins.Function, {
name: "Function",
length: 1,
@@ -39,6 +58,16 @@ const functionGlobal = <R>(ctx: Interpreter<R>) => {
})
}
// CreateListFromArrayLike: `apply` reads `length` and the indexed properties of any object.
const listFromArrayLike = (value: Value): Array<Value> => {
if (value === undefined || value === null) return []
if (value instanceof Arr) return [...value.items]
if (!(value instanceof Obj)) throw typeError("Function.prototype.apply expects an array-like argument list.")
const length = Math.max(0, coerceToInteger(get(value, "length")))
checkArrayLength(length)
return Array.from({ length }, (_, index) => get(value, String(index)))
}
const symbolGlobal = <R>(ctx: Interpreter<R>) => {
const symbol = native<R>(ctx.builtins, {
name: "Symbol",
@@ -82,6 +111,8 @@ const table: Record<string, Factory> = {
RegExp: (ctx) => regexpGlobal(ctx),
Map: (ctx) => mapGlobal(ctx),
Set: (ctx) => setGlobal(ctx),
WeakMap: (ctx) => weakMapGlobal(ctx),
WeakSet: (ctx) => weakSetGlobal(ctx),
URL: (ctx) => urlGlobal(ctx),
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
Headers: (ctx) => headersGlobal(ctx),
+220 -115
View File
@@ -1,4 +1,5 @@
import type {
AnyNode,
ArrayExpression,
ArrayPattern,
AssignmentPattern,
@@ -81,6 +82,7 @@ import {
keys,
Native,
parseArrayIndex,
Arguments,
Arr,
Fn,
GeneratorObj,
@@ -93,7 +95,7 @@ import {
coerceToString,
type Value,
} from "./objects.js"
import { preserveConsumerError } from "./callback.js"
import { type Hint, preserveConsumerError, toPrimitive } from "./callback.js"
import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
import { describeValue, isOpaque, rejectCircularInsertion, typeofValue } from "./references.js"
import { ScopeStack } from "./scope.js"
@@ -101,6 +103,30 @@ import { constructRegExp } from "../stdlib/regexp.js"
import { enumerableSource } from "../stdlib/object.js"
import { compoundOperators } from "../stdlib/value.js"
/** The binary operators that convert object operands through ToPrimitive before acting on primitives. */
const primitiveOperators = new Set([
"+",
"-",
"*",
"/",
"%",
"**",
"<",
"<=",
">",
">=",
"&",
"|",
"^",
"<<",
">>",
">>>",
])
/** ToPropertyKey on a primitive (or an opaque value, which keeps its built-in string form). */
const propertyKey = (value: Value): PropertyKey =>
typeof value === "string" || typeof value === "number" || typeof value === "symbol" ? value : coerceToString(value)
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefined): StatementResult | undefined => {
@@ -178,6 +204,24 @@ const collectPatternNames = (pattern: Pattern, out: Array<string> = []): Array<s
return out
}
// Whether a function body (or a parameter default) reads `arguments`, looking through arrows but not nested
// functions, which own theirs. Memoized so the object is only built for calls that can observe it.
const argumentsUse = new WeakMap<Fn["body"], boolean>()
const usesArguments = (fn: Fn): boolean => {
const cached = argumentsUse.get(fn.body)
if (cached !== undefined) return cached
const found = [...fn.parameters, fn.body].some(function visit(node: AnyNode | null): boolean {
if (node === null || typeof node !== "object") return false
if (node.type === "Identifier") return node.name === "arguments"
if (node.type === "FunctionDeclaration" || node.type === "FunctionExpression") return false
return Object.values(node).some((child) =>
Array.isArray(child) ? child.some((item) => visit(item)) : visit(child as AnyNode | null),
)
})
argumentsUse.set(fn.body, found)
return found
}
// `var` names declared anywhere in a function body except inside nested functions, which own theirs.
// Memoized per body: a function's var names never change, and hoisting runs on every call.
const varNames = new WeakMap<ReadonlyArray<Statement | ModuleDeclaration>, ReadonlyArray<string>>()
@@ -287,7 +331,8 @@ export class Interpreter<R> {
this.pending = options.pending
this.builtins = options.builtins
this.logs = options.logs ?? []
const globalScope = new Map<string, Binding>()
// Program code has no receiver: top-level `this` is undefined, as in a module.
const globalScope = new Map<string, Binding>([["this", { mutable: false, value: undefined }]])
// Calling back into the program never reads frame state, so any frame serves; the root is always alive.
this.root = new Frame(this, new ScopeStack([globalScope]))
for (const [name, value] of [...globals(this), ...(options.globals?.(this) ?? [])]) {
@@ -468,6 +513,7 @@ class Frame<R> {
this.scopes.capture(),
node.async,
node.generator,
node.type === "ArrowFunctionExpression",
)
// Each generator function gets its own prototype, so `g() instanceof g` holds as in JS.
if (node.generator)
@@ -1094,18 +1140,15 @@ class Frame<R> {
}
if (pattern.type === "ObjectPattern") {
if (!(value instanceof Obj)) {
throw typeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
)
if (value === null || value === undefined) {
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
}
const consumed = new Set<PropertyKey>()
for (const property of pattern.properties) {
if (property.type === "RestElement") {
const rest = new Obj(self.ctx.builtins.Object)
assign(rest, value, consumed)
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
yield* self.declarePattern(property.argument, rest, mutable, property, initialize)
continue
}
@@ -1114,7 +1157,7 @@ class Frame<R> {
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
property.value,
self.readProperty(value, key, property),
self.destructuredProperty(value, key, property),
mutable,
property,
initialize,
@@ -1153,24 +1196,21 @@ class Frame<R> {
}
if (pattern.type === "ObjectPattern") {
if (!(value instanceof Obj)) {
throw invalidData(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
)
if (value === null || value === undefined) {
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
}
const consumed = new Set<PropertyKey>()
for (const property of pattern.properties) {
if (property.type === "RestElement") {
const rest = new Obj(self.ctx.builtins.Object)
assign(rest, value, consumed)
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
yield* self.assignPattern(property.argument, rest, property)
continue
}
const key = yield* self.destructuringPropertyKey(property)
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(property.value, self.readProperty(value, key, property), property)
yield* self.assignPattern(property.value, self.destructuredProperty(value, key, property), property)
}
return
}
@@ -1241,7 +1281,7 @@ class Frame<R> {
}
const keyNode = property.key
if (property.computed) {
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value))
return Effect.flatMap(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
}
if (keyNode.type === "Identifier") return Effect.succeed(keyNode.name)
if (keyNode.type === "Literal") return Effect.succeed(String(keyNode.value))
@@ -1257,6 +1297,8 @@ class Frame<R> {
}
case "Identifier":
return Effect.sync(() => this.scopes.get(node.name, node))
case "ThisExpression":
return Effect.sync(() => this.scopes.get("this", node))
case "BinaryExpression":
return this.evaluateBinaryExpression(node)
case "LogicalExpression":
@@ -1344,85 +1386,105 @@ class Frame<R> {
const lhs = yield* self.evaluateExpression(left)
const rhs = yield* self.evaluateExpression(node.right)
if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
if (lhs instanceof Obj || rhs instanceof Obj) return yield* self.applyOperator(operator, lhs, rhs, node)
return self.applyBinaryOperator(operator, lhs, rhs, node)
})
}
/** ToPrimitive for an operand: data objects run their own methods; opaque values stay for the data gates below. */
private toPrimitive(value: Value, hint: Hint, node: AstNode) {
return this.native(() => toPrimitive(this.ctx, value, hint), node)
}
// Arithmetic, relational, and bitwise operators convert both operands first, left then right, so a `valueOf`
// runs (and throws) in spec order; `+` asks for the default hint and the rest for a number.
private applyOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
if (!(lhs instanceof Obj || rhs instanceof Obj))
return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
// IsLooselyEqual converts only an object facing a non-nullish primitive; two objects (including tool
// references, which are not Obj) compare by identity.
const equality = operator === "==" || operator === "!="
// `in` checks the right operand before ToPropertyKey on the left, so a bad right side wins over a bad key.
if (operator === "in" && lhs instanceof Obj && !isOpaque(lhs) && rhs instanceof Obj) {
return Effect.map(this.toPropertyKey(lhs, node), (key) => has(rhs, key))
}
const other = lhs instanceof Obj ? rhs : lhs
const converts =
primitiveOperators.has(operator) ||
(equality && other !== null && other !== undefined && typeof other !== "object")
if (!converts) return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
const hint = operator === "+" || equality ? "default" : "number"
const self = this
return Effect.gen(function* () {
const l = yield* self.toPrimitive(lhs, hint, node)
const r = yield* self.toPrimitive(rhs, hint, node)
return self.applyBinaryOperator(operator, l, r, node)
})
}
private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value {
if (operator === "===") return lhs === rhs
if (operator === "!==") return lhs !== rhs
if (operator === "==") return this.looselyEqual(lhs, rhs, node)
if (operator === "!=") return !this.looselyEqual(lhs, rhs, node)
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) {
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
}
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) return has(rhs, propertyKey(lhs))
if (isOpaque(lhs) || isOpaque(rhs)) {
throw invalidData("Binary operators require data values.", node)
}
// Addition uses the default hint; every other operator asks for a number.
const hint = operator === "+" ? "default" : "number"
const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand)
const l = coerceOperand(lhs)
const r = coerceOperand(rhs)
// Object operands were already converted by applyOperator; only primitives reach the arithmetic below.
switch (operator) {
case "+": {
const sum = (l as string) + (r as string)
const sum = (lhs as string) + (rhs as string)
if (typeof sum === "string") checkStringLength(sum.length)
return sum
}
case "-":
return (l as number) - (r as number)
return (lhs as number) - (rhs as number)
case "*":
return (l as number) * (r as number)
return (lhs as number) * (rhs as number)
case "/":
return (l as number) / (r as number)
return (lhs as number) / (rhs as number)
case "%":
return (l as number) % (r as number)
return (lhs as number) % (rhs as number)
case "**":
return (l as number) ** (r as number)
return (lhs as number) ** (rhs as number)
case "<":
return (l as string) < (r as string)
return (lhs as string) < (rhs as string)
case "<=":
return (l as string) <= (r as string)
return (lhs as string) <= (rhs as string)
case ">":
return (l as string) > (r as string)
return (lhs as string) > (rhs as string)
case ">=":
return (l as string) >= (r as string)
return (lhs as string) >= (rhs as string)
case "&":
return (l as number) & (r as number)
return (lhs as number) & (rhs as number)
case "|":
return (l as number) | (r as number)
return (lhs as number) | (rhs as number)
case "^":
return (l as number) ^ (r as number)
return (lhs as number) ^ (rhs as number)
case "<<":
return (l as number) << (r as number)
return (lhs as number) << (rhs as number)
case ">>":
return (l as number) >> (r as number)
return (lhs as number) >> (rhs as number)
case ">>>":
return (l as number) >>> (r as number)
return (lhs as number) >>> (rhs as number)
case "in":
if (!(rhs instanceof Obj)) {
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
}
return has(rhs, coerceOperand(lhs) as PropertyKey)
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
default:
throw typeError(`Unsupported binary operator '${operator}'.`, node)
}
}
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and only a
// data object facing a non-nullish primitive needs to coerce, so an opaque value is rejected only there.
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and a nullish
// primitive never equals an object.
private looselyEqual(lhs: Value, rhs: Value, node: AstNode): boolean {
const lhsObject = lhs !== null && typeof lhs === "object"
const rhsObject = rhs !== null && typeof rhs === "object"
if (lhsObject === rhsObject) return lhsObject ? lhs === rhs : lhs == rhs
const object = lhsObject ? lhs : rhs
const primitive = lhsObject ? rhs : lhs
if (primitive === null || primitive === undefined) return false
if (!(object instanceof Obj) || isOpaque(object)) {
throw invalidData("Binary operators require data values.", node)
}
return object.toPrimitive("default") == primitive
// Data objects were converted by applyOperator, so only an opaque reference facing a primitive gets here.
throw invalidData("Binary operators require data values.", node)
}
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<Value, unknown, R> {
@@ -1444,14 +1506,16 @@ class Frame<R> {
if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(argument.name)) {
return Effect.succeed("undefined")
}
return Effect.map(this.evaluateExpression(argument), (value) => {
const self = this
return Effect.gen(function* () {
const value = yield* self.evaluateExpression(argument)
if (operator === "typeof") return typeofValue(value)
if (operator === "!") return !value
if (operator === "void") return undefined
if (isOpaque(value)) {
const operand = yield* self.toPrimitive(value, "number", node)
if (isOpaque(operand)) {
throw invalidData("Unary operators require data values.", node)
}
const operand = value instanceof Obj ? value.toPrimitive("number") : value
let result: Value
switch (operator) {
case "+":
@@ -1473,11 +1537,16 @@ class Frame<R> {
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<Value, unknown, R> {
const left = node.left
const operator = node.operator
// The binary operator a compound assignment applies: `+=` is `+`.
const binary = operator.slice(0, -1)
const self = this
return Effect.gen(function* () {
if (operator === "??=" || operator === "||=" || operator === "&&=") {
return yield* self.evaluateLogicalAssignment(node, left, operator)
}
if (operator !== "=" && !compoundOperators.has(operator)) {
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
}
if (operator === "=" && (left.type === "ObjectPattern" || left.type === "ArrayPattern")) {
const rightValue = yield* self.evaluateExpression(node.right)
yield* self.assignPattern(left, rightValue, node)
@@ -1488,17 +1557,24 @@ class Frame<R> {
if (operator !== "=") {
const current = self.scopes.get(name, left)
const rightValue = yield* self.evaluateExpression(node.right)
return self.scopes.set(name, self.applyCompoundAssignment(operator, current, rightValue, node), left)
const next =
current instanceof Obj || rightValue instanceof Obj
? yield* self.applyOperator(binary, current, rightValue, node)
: self.applyBinaryOperator(binary, current, rightValue, node)
return self.scopes.set(name, next, left)
}
const rightValue = yield* self.evaluateNamed(node.right, name)
return self.scopes.set(name, rightValue, left)
}
if (left.type === "MemberExpression") {
return yield* self.modifyMember(left, (current) =>
Effect.map(self.evaluateExpression(node.right), (rightValue) => {
if (operator === "=") return { write: true, next: rightValue, result: rightValue }
const next = self.applyCompoundAssignment(operator, current, rightValue, node)
return { write: true, next, result: next }
Effect.flatMap(self.evaluateExpression(node.right), (rightValue) => {
if (operator === "=") return Effect.succeed({ write: true, next: rightValue, result: rightValue })
return Effect.map(self.applyOperator(binary, current, rightValue, node), (next) => ({
write: true,
next,
result: next,
}))
}),
)
}
@@ -1548,8 +1624,7 @@ class Frame<R> {
throw typeError(`Unsupported update operator '${operator}'.`, node)
}
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
// CodeMode numeric coercion, not host Number(), so opaque runtime references reject clearly.
const operand = (current: Value): number => {
if (isOpaque(current)) {
throw invalidData(`'${operator}' requires a data value.`, argument)
@@ -1558,21 +1633,26 @@ class Frame<R> {
}
if (argument.type === "Identifier") {
return Effect.sync(() => {
const name = argument.name
const current = operand(this.scopes.get(name, argument))
const next = current + increment
const name = argument.name
const current = this.scopes.get(name, argument)
const update = (value: Value) => {
const before = operand(value)
const next = before + increment
this.scopes.set(name, next, argument)
return prefix ? next : current
})
return prefix ? next : before
}
if (!(current instanceof Obj)) return Effect.sync(() => update(current))
return Effect.map(this.toPrimitive(current, "number", argument), update)
}
if (argument.type === "MemberExpression") {
return this.modifyMember(argument, (current) => {
const value = operand(current)
const next = value + increment
return Effect.succeed({ write: true, next, result: prefix ? next : value })
})
return this.modifyMember(argument, (current) =>
Effect.map(this.toPrimitive(current, "number", argument), (primitive) => {
const value = operand(primitive)
const next = value + increment
return { write: true, next, result: prefix ? next : value }
}),
)
}
throw typeError("Update target must be an Identifier or MemberExpression.", argument)
@@ -1622,7 +1702,7 @@ class Frame<R> {
}
return yield* self.createToolCallPromise(callable.path, args)
}
if (callable instanceof Fn) return yield* self.invokeFunction(callable, args, node)
if (callable instanceof Fn) return yield* self.invokeFunction(callable, thisValue, args, node)
if (callable instanceof Native) {
return yield* self.native(() => (callable as Native<R>).call(thisValue, args), node)
}
@@ -1664,14 +1744,24 @@ class Frame<R> {
}
// A callback invoked by a built-in runs below the call that invoked the built-in, so the deeper of the two counts.
invokeFunction(fn: Fn, args: Array<Value>, node?: AstNode): Effect.Effect<Value, unknown, R> {
invokeFunction(fn: Fn, thisValue: Value, args: Array<Value>, node?: AstNode): Effect.Effect<Value, unknown, R> {
const self = this
return Effect.flatMap(CallSite, (site) => {
const depth = Math.max(self.depth, site.depth) + 1
if (depth > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node)
const invocation = new Frame(this.ctx, new ScopeStack([...fn.capturedScopes, new Map()]), depth)
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
const paramScope = invocation.scopes.current()
// `this` and `arguments` are scope bindings so arrows resolve them lexically; a parameter named
// `arguments` shadows the object, as in JS.
if (!fn.arrow) paramScope.set("this", { mutable: false, value: thisValue, initialized: true })
if (!fn.arrow && usesArguments(fn)) {
paramScope.set("arguments", {
mutable: true,
value: new Arguments(self.ctx.builtins.Object, args),
initialized: true,
})
}
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
for (const parameter of fn.parameters) {
for (const name of collectPatternNames(parameter)) {
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
@@ -1962,11 +2052,11 @@ class Frame<R> {
let key: PropertyKey
if (property.computed) {
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode))
key = yield* self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
} else if (keyNode.type === "Identifier") {
key = keyNode.name
} else if (keyNode.type === "Literal") {
key = self.toPropertyKey(literal(keyNode))
key = propertyKey(literal(keyNode))
} else {
throw typeError("Unsupported object property key shape.", keyNode)
}
@@ -2031,7 +2121,7 @@ class Frame<R> {
if (index < expressions.length) {
const raw = yield* self.evaluateExpression(expressions[index])
output += coerceToString(raw)
output += coerceToString(yield* self.toPrimitive(raw, "string", expressions[index]))
checkStringLength(output.length)
}
}
@@ -2081,13 +2171,6 @@ class Frame<R> {
)
}
private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value {
if (!compoundOperators.has(operator)) {
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
}
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
}
private getMemberReference(
node: MemberExpression,
): Effect.Effect<MemberReference | ToolReference | { value: Value } | typeof OptionalShortCircuit, unknown, R> {
@@ -2101,37 +2184,58 @@ class Frame<R> {
if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
if ((objectValue === null || objectValue === undefined) && node.optional) return OptionalShortCircuit
const key = node.computed
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
: propertyNode.type === "Identifier"
const keyValue =
!node.computed && propertyNode.type === "Identifier"
? propertyNode.name
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
}
const proto = primitivePrototype(self.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
: yield* self.evaluateExpression(propertyNode)
// GetValue applies ToObject to the base before ToPropertyKey, so a nullish base throws before the key's own
// toString runs.
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
throw typeError(`Cannot read properties of ${objectValue} (reading '${coerceToString(keyValue)}').`, objectNode)
}
throw typeError("Cannot access a property on a non-object value.", objectNode)
const key = yield* self.toPropertyKey(keyValue, propertyNode)
return self.resolveProperty(objectValue, key, objectNode, propertyNode)
})
}
private resolveProperty(
objectValue: Value,
key: PropertyKey,
objectNode: AstNode,
propertyNode: AstNode,
): MemberReference | ToolReference | { value: Value } {
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
}
const proto = primitivePrototype(this.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
}
throw typeError("Cannot access a property on a non-object value.", objectNode)
}
// One destructured property, read the way a member expression would read it (primitives use their prototype).
private destructuredProperty(source: Value, key: PropertyKey, node: AstNode): Value {
const reference = this.resolveProperty(source, key, node, node)
if (reference instanceof ToolReference) return reference
if ("value" in reference) return reference.value
return this.readProperty(reference.target, reference.key, node, reference.receiver)
}
private readReference(reference: MemberReference, node: MemberExpression): Value {
// Reject unknown promise properties so a missing await cannot hide.
if (reference.target instanceof PromiseObj && !has(reference.target, reference.key)) {
@@ -2221,9 +2325,10 @@ class Frame<R> {
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
// ToPropertyKey: anything else becomes its string form, so `counts[row.category]` works when the field is null.
private toPropertyKey(value: Value): PropertyKey {
if (typeof value === "string" || typeof value === "number" || typeof value === "symbol") return value
return coerceToString(value)
// ToPropertyKey: a data object converts through its own `toString`/`valueOf` first; anything else becomes its
// string form synchronously, so `counts[row.category]` works when the field is null.
private toPropertyKey(value: Value, node: AstNode): Effect.Effect<PropertyKey, unknown, R> {
if (!(value instanceof Obj)) return Effect.succeed(propertyKey(value))
return Effect.map(this.toPrimitive(value, "string", node), propertyKey)
}
}
@@ -27,6 +27,8 @@ const builtins = [
"RegExp",
"Map",
"Set",
"WeakMap",
"WeakSet",
"URL",
"URLSearchParams",
"Headers",
@@ -88,6 +90,8 @@ export const createBuiltins = (): Builtins => {
RegExp: plain(),
Map: plain(),
Set: plain(),
WeakMap: plain(),
WeakSet: plain(),
URL: plain(),
URLSearchParams: plain(),
Headers: plain(),
+1 -1
View File
@@ -72,7 +72,7 @@ export const uriError = failure("URIError")
// Orient the agent rather than enumerate JavaScript; interpreter-support.md is the full matrix.
export const supportedSyntaxMessage =
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, BigInt, and custom Symbols. Use plain functions and data objects instead."
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, getters/setters, BigInt, and custom Symbols. Use plain functions and data objects instead."
export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
new PendingThrow(
+39 -1
View File
@@ -149,7 +149,11 @@ export abstract class Opaque extends Obj {
export abstract class Callable extends Opaque {
override readonly tag = "Function"
constructor(proto: Obj, name: string, length: number) {
constructor(
proto: Obj,
name: string,
readonly length: number,
) {
super(proto)
define(this, "length", length, readonly)
define(this, "name", name, readonly)
@@ -168,12 +172,29 @@ export class Fn extends Callable {
readonly capturedScopes: Array<Map<string, Binding>>,
readonly async: boolean,
readonly generator: boolean,
/** Arrows have no `this` or `arguments` of their own; they read the enclosing function's. */
readonly arrow: boolean,
) {
const optional = parameters.findIndex((p) => p.type === "AssignmentPattern" || p.type === "RestElement")
super(proto, name, optional === -1 ? parameters.length : optional)
}
}
/** The strict `arguments` object: an ordinary object with indexed own properties and a hidden `length`. */
export class Arguments extends Obj {
override readonly tag = "Arguments"
constructor(proto: Obj, args: Array<Value>) {
super(proto)
args.forEach((arg, index) => define(this, String(index), arg))
define(this, "length", args.length, hidden)
}
override iterator() {
return keys(this)
.map((key) => get(this, key))
.values()
}
}
export type NativeCall<R> = (thisValue: Value, args: Array<Value>) => Effect.Effect<Value, unknown, R>
export type NativeConstruct<R> = (args: Array<Value>, newTarget: Callable) => Effect.Effect<Value, unknown, R>
@@ -335,6 +356,23 @@ export class SetObj extends Wrapper {
}
}
/** Keys are program objects, so a host WeakMap gives the same lifetime rule as JavaScript without any bookkeeping. */
export class WeakMapObj extends Wrapper {
override readonly tag = "WeakMap"
readonly map = new WeakMap<Obj, Value>()
override inspect() {
return "WeakMap { <items unknown> }"
}
}
export class WeakSetObj extends Wrapper {
override readonly tag = "WeakSet"
readonly set = new WeakSet<Obj>()
override inspect() {
return "WeakSet { <items unknown> }"
}
}
export class URLSearchParamsObj extends Wrapper {
override readonly tag = "URLSearchParams"
constructor(
+28 -13
View File
@@ -18,7 +18,7 @@ import {
type Value,
} from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
import { applyCollectionCallback, invoke, preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { compareText } from "../tool-runtime.js"
@@ -49,7 +49,7 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
const values: Array<Value> = []
for (let index = 0; index < arrayLike.length; index += 1) {
const item = get(arrayLike.source, index)
values.push(apply === undefined ? item : yield* apply([item, index]))
values.push(apply === undefined ? item : yield* apply([item, index], args[2]))
}
return new Arr(proto, values)
}
@@ -59,7 +59,9 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
const step = yield* cursor.next
if (step.done) return new Arr(proto, values)
values.push(
apply === undefined ? step.value : yield* preserveConsumerError(cursor.close, apply([step.value, index])),
apply === undefined
? step.value
: yield* preserveConsumerError(cursor.close, apply([step.value, index], args[2])),
)
index += 1
}
@@ -146,20 +148,30 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
"join",
1,
(thisValue, args) => {
const joined = self(thisValue, "join")
.items.map((item) => coerceToString(item ?? ""))
.join(args[0] === undefined ? "," : coerceToString(args[0]))
checkStringLength(joined.length)
return joined
// .map would keep holes, which Effect.forEach would then hand to the body as undefined.
const parts = Array.from(self(thisValue, "join").items, (item) => item ?? "")
return withPrimitives(
ctx,
"string",
[args[0] === undefined ? "," : args[0], ...parts],
([separator, ...items]) => {
const joined = items.map(coerceToString).join(coerceToString(separator))
checkStringLength(joined.length)
return joined
},
)
},
],
[
"toString",
0,
(thisValue) =>
self(thisValue, "toString")
.items.map((item) => coerceToString(item ?? ""))
.join(","),
withPrimitives(
ctx,
"string",
Array.from(self(thisValue, "toString").items, (item) => item ?? ""),
(items) => items.map(coerceToString).join(","),
),
],
[
"includes",
@@ -362,7 +374,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
const values: Array<Value> = []
for (let index = 0; index < length; index += 1) {
if (!(index in target.items)) continue
const mapped = yield* apply([target.items[index], index, target])
const mapped = yield* apply([target.items[index], index, target], args[1])
if (mapped instanceof Arr) values.push(...mapped.items)
else values.push(mapped)
}
@@ -401,7 +413,10 @@ export const callbackMethods = <R, T extends Obj>(
length,
(thisValue, args) => {
const target = self(thisValue, name)
return body(elements(target), target, applyCollectionCallback(ctx, args[0], `${label}.${name}`), args)
const call = applyCollectionCallback(ctx, args[0], `${label}.${name}`)
// reduce and reduceRight take an initial value where the others take a thisArg.
const thisArg = name.startsWith("reduce") ? undefined : args[1]
return body(elements(target), target, (callbackArgs) => call(callbackArgs, thisArg), args)
},
]
return [
+135 -2
View File
@@ -16,6 +16,8 @@ import {
PromiseObj,
SetObj,
type Value,
WeakMapObj,
WeakSetObj,
} from "../interpreter/objects.js"
import { describeValue, isOpaque } from "../interpreter/references.js"
import {
@@ -188,7 +190,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Map.forEach")
return Effect.gen(function* () {
for (const [key, item] of target.map.entries()) yield* apply([item, key, target])
for (const [key, item] of target.map.entries()) yield* apply([item, key, target], args[1])
return undefined
})
},
@@ -386,7 +388,7 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Set.forEach")
return Effect.gen(function* () {
for (const item of target.set.values()) yield* apply([item, item, target])
for (const item of target.set.values()) yield* apply([item, item, target], args[1])
return undefined
})
},
@@ -402,3 +404,134 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
define(proto, IteratorSymbol, get(proto, "values"), hidden)
return set
}
// CanBeHeldWeakly: only program objects; tool references are rebuilt on every access, so they could never be found again.
const weakKey = (value: Value, label: string) => {
if (value instanceof Obj) return value
throw typeError(`Invalid value used ${label}: ${describeValue(value)} cannot be held weakly.`)
}
export const weakMapGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.WeakMap
const weakMap = constructor<R>(builtins, proto, {
name: "WeakMap",
call: requiresNew("WeakMap"),
construct: (args, newTarget) => {
const target = new WeakMapObj(prototypeFrom(newTarget, proto))
if (args[0] === undefined || args[0] === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* ctx.iterate(args[0]!)
if (cursor === undefined) {
throw typeError(
`new WeakMap(...) expects an iterable of [key, value] pairs, received ${describeValue(args[0])}.`,
)
}
while (true) {
const step = yield* cursor.next
if (step.done) return target
yield* preserveConsumerError(
cursor.close,
Effect.sync(() => {
if (!(step.value instanceof Obj)) {
throw typeError("new WeakMap(...) expects [key, value] pairs as entry objects.")
}
target.map.set(weakKey(getOwn(step.value, 0), "as weak map key"), getOwn(step.value, 1))
}),
)
}
})
},
})
const self = (thisValue: Value, name: string) => receiver(WeakMapObj, thisValue, `WeakMap.prototype.${name}`).map
// Lookups pass any key through: the host collection answers false for a non-object, as the spec requires.
const key = (value: Value) => weakKey(value, "as weak map key")
methods(builtins, proto, [
[
"get",
1,
(thisValue, args) => {
const target = self(thisValue, "get")
return args[0] instanceof Obj ? target.get(args[0]) : undefined
},
],
["has", 1, (thisValue, args) => self(thisValue, "has").has(args[0] as Obj)],
["delete", 1, (thisValue, args) => self(thisValue, "delete").delete(args[0] as Obj)],
[
"set",
2,
(thisValue, args) => {
self(thisValue, "set").set(key(args[0]), args[1])
return thisValue
},
],
[
"getOrInsert",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsert")
const k = key(args[0])
if (!target.has(k)) target.set(k, args[1])
return target.get(k)
},
],
[
"getOrInsertComputed",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsertComputed")
const k = key(args[0])
const apply = applyCollectionCallback(ctx, args[1], "WeakMap.getOrInsertComputed")
if (target.has(k)) return target.get(k)
return Effect.map(apply([k]), (value) => {
target.set(k, value)
return value
})
},
],
])
return weakMap
}
export const weakSetGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.WeakSet
const weakSet = constructor<R>(builtins, proto, {
name: "WeakSet",
call: requiresNew("WeakSet"),
construct: (args, newTarget) => {
const target = new WeakSetObj(prototypeFrom(newTarget, proto))
if (args[0] === undefined || args[0] === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* ctx.iterate(args[0]!)
if (cursor === undefined) {
throw typeError(`new WeakSet(...) expects a synchronous iterable, received ${describeValue(args[0])}.`)
}
while (true) {
const step = yield* cursor.next
if (step.done) return target
yield* preserveConsumerError(
cursor.close,
Effect.sync(() => {
target.set.add(weakKey(step.value, "in weak set"))
}),
)
}
})
},
})
const self = (thisValue: Value, name: string) => receiver(WeakSetObj, thisValue, `WeakSet.prototype.${name}`).set
methods(builtins, proto, [
["has", 1, (thisValue, args) => self(thisValue, "has").has(args[0] as Obj)],
["delete", 1, (thisValue, args) => self(thisValue, "delete").delete(args[0] as Obj)],
[
"add",
1,
(thisValue, args) => {
self(thisValue, "add").add(weakKey(args[0], "in weak set"))
return thisValue
},
],
])
return weakSet
}
+16 -3
View File
@@ -16,8 +16,11 @@ const constructDate = <R>(ctx: Interpreter<R>, args: Array<Value>, proto: Obj) =
: new DateObj(proto, new Date(coerceToNumber(value)).getTime()),
)
}
const parts = args.map((arg) => coerceToNumber(arg))
return Effect.succeed(new DateObj(proto, new Date(...(parts as [number, number])).getTime()))
// The spec converts at most seven components, in order, so extra arguments never run program code.
return Effect.map(
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
(parts) => new DateObj(proto, new Date(...(parts as [number, number])).getTime()),
)
}
type Getter = keyof {
@@ -79,7 +82,15 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
methods(builtins, date, [
["now", 0, () => Date.now()],
["parse", 1, (_, args) => Date.parse(coerceToString(args[0]))],
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
[
"UTC",
7,
(_, args) =>
Effect.map(
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
(parts) => Date.UTC(...(parts as Parameters<typeof Date.UTC>)),
),
],
])
const self = (thisValue: Value, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`)
@@ -125,6 +136,8 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
concurrency: 1,
}),
(values) => {
// Every setter but setTime and setFullYear leaves an invalid Date untouched and answers NaN.
if (Number.isNaN(hosted.getTime()) && name !== "setTime" && !name.endsWith("FullYear")) return NaN
target.time = hosted[name](...(values as [number, number, number, number]))
return target.time
},
+1 -1
View File
@@ -117,7 +117,7 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target], args[1])
return undefined
})
},
+1 -1
View File
@@ -40,7 +40,7 @@ const parse = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Value,
else set(value, name, revived)
}
}
return yield* apply([key, value])
return yield* apply([key, value], holder)
})
return visit(record(ctx.builtins.Object, { "": parsed }), "")
}
+19 -17
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { constants, type Method, methods } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import { Obj, coerceToNumber } from "../interpreter/objects.js"
import { preserveConsumerError } from "../interpreter/callback.js"
import { preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
// Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types.
@@ -12,25 +12,27 @@ declare global {
}
}
// Validate only the arguments a method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(coerceToNumber(args[0]))]
const binary = (name: string, op: (a: number, b: number) => number): Method => [
name,
2,
(_, args) => op(coerceToNumber(args[0]), coerceToNumber(args[1])),
]
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
name,
2,
(_, args) => op(...args.map(coerceToNumber)),
]
export const mathGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const math = new Obj(builtins.Object)
// Convert only the arguments a method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const unary = (name: string, op: (a: number) => number): Method => [
name,
1,
(_, args) => withPrimitives(ctx, "number", [args[0]], ([a]) => op(coerceToNumber(a))),
]
const binary = (name: string, op: (a: number, b: number) => number): Method => [
name,
2,
(_, args) =>
withPrimitives(ctx, "number", [args[0], args[1]], ([a, b]) => op(coerceToNumber(a), coerceToNumber(b))),
]
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
name,
2,
(_, args) => withPrimitives(ctx, "number", args, (values) => op(...values.map(coerceToNumber))),
]
constants(math, {
PI: Math.PI,
E: Math.E,
+26 -35
View File
@@ -1,8 +1,9 @@
import { constructor, constants, methods } from "../interpreter/native.js"
import { coerceToNumber, coerceToString, type Value } from "../interpreter/objects.js"
import { constructor, constants, type Method, methods } from "../interpreter/native.js"
import { coerceToNumber, type Value } from "../interpreter/objects.js"
import { rangeError, typeError } from "../interpreter/model.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coercion } from "./value.js"
import { withPrimitives } from "../interpreter/callback.js"
import { coerce, coercion } from "./value.js"
export const numberGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
@@ -26,46 +27,36 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
["isFinite", 1, (_, args) => Number.isFinite(args[0])],
["isNaN", 1, (_, args) => Number.isNaN(args[0])],
["isSafeInteger", 1, (_, args) => Number.isSafeInteger(args[0])],
[
"parseInt",
2,
(_, args) => {
return parseInt(coerceToString(args[0]), coerceToNumber(args[1]))
},
],
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
["parseInt", 2, (_, args) => coerce(ctx, "parseInt", args)],
["parseFloat", 1, (_, args) => coerce(ctx, "parseFloat", args)],
])
const self = (thisValue: Value, name: string): number => {
if (typeof thisValue === "number") return thisValue
throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`)
}
const optNum = (arg: Value): number | undefined => (arg === undefined ? undefined : coerceToNumber(arg))
// The receiver is checked first, then the one argument converts through ToPrimitive with the number hint.
const formatting = (name: string, op: (value: number, digits: number | undefined) => string): Method => [
name,
1,
(thisValue, args) => {
const value = self(thisValue, name)
return withPrimitives(ctx, "number", [args[0]], ([digits]) =>
op(value, digits === undefined ? undefined : coerceToNumber(digits)),
)
},
]
methods(builtins, builtins.Number, [
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum(args[0]))],
formatting("toFixed", (value, digits) => value.toFixed(digits)),
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
["toExponential", 1, (thisValue, args) => self(thisValue, "toExponential").toExponential(optNum(args[0]))],
[
"toPrecision",
1,
(thisValue, args) => {
const value = self(thisValue, "toPrecision")
const digits = optNum(args[0])
return digits === undefined ? value.toString() : value.toPrecision(digits)
},
],
[
"toString",
1,
(thisValue, args) => {
const value = self(thisValue, "toString")
const radix = optNum(args[0])
if (radix !== undefined && (radix < 2 || radix > 36)) {
throw rangeError("Number.toString radix must be between 2 and 36.")
}
return value.toString(radix)
},
],
formatting("toExponential", (value, digits) => value.toExponential(digits)),
formatting("toPrecision", (value, digits) => (digits === undefined ? value.toString() : value.toPrecision(digits))),
formatting("toString", (value, radix) => {
if (radix !== undefined && (radix < 2 || radix > 36)) {
throw rangeError("Number.toString radix must be between 2 and 36.")
}
return value.toString(radix)
}),
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
])
return number
+200 -115
View File
@@ -17,7 +17,13 @@ import {
type Value,
} from "../interpreter/objects.js"
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
import { applyCollectionCallback, isSupportedCallback, toPrimitiveString } from "../interpreter/callback.js"
import {
applyCollectionCallback,
type Hint,
isSupportedCallback,
toPrimitiveString,
withPrimitives,
} from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { matchToValue, toHostRegex } from "./regexp.js"
import { coercion } from "./value.js"
@@ -142,34 +148,72 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
args[index] === undefined ? undefined : num(name, args, index)
const optStr = (name: string, args: Array<Value>, index: number): string | undefined =>
args[index] === undefined ? undefined : str(name, args, index)
const rejectRegex = (name: string, args: Array<Value>): void => {
if (args[0] instanceof RegExpObj) {
throw typeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
)
}
}
// ToPrimitive in spec order: the receiver, then the arguments the method consumes, one hint per position; the
// rest pass through as they are.
const simple = (
name: string,
length: number,
op: (value: string, args: Array<Value>) => ReturnType<Impl>,
): Method => [name, length, (thisValue, args) => op(self(thisValue, name), args)]
const replace = (name: "replace" | "replaceAll") =>
simple(name, 2, (value, args) => {
if (isSupportedCallback(args[1])) return replaceWithCallback(ctx, value, name, args)
if (typeofValue(args[1]) === "function") {
hints: ReadonlyArray<Hint> = [],
): Method => [
name,
length,
(thisValue, args) => {
if (thisValue === null || thisValue === undefined) {
throw typeError(`String.prototype.${name} called on null or undefined.`)
}
return withPrimitives(
ctx,
["string", ...hints],
[thisValue, ...args.slice(0, hints.length)],
([value, ...primitives]) => op(coerceToString(value), [...primitives, ...args.slice(hints.length)]),
)
},
]
// includes, startsWith, and endsWith reject a RegExp before converting their search string and position.
const searching = (name: string, op: (value: string, search: string, position: number | undefined) => boolean) =>
simple(name, 1, (value, args) => {
if (args[0] instanceof RegExpObj) {
throw typeError(
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
)
}
if (args[0] instanceof RegExpObj) {
const pattern = args[0].regex
const replacement = str(name, args, 1)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern)
return name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
}
if (name === "replace") return value.replace(str(name, args, 0), str(name, args, 1))
return value.replaceAll(str(name, args, 0), str(name, args, 1))
return withPrimitives(ctx, ["string", "number"], args.slice(0, 2), (primitives) =>
op(value, str(name, primitives, 0), optNum(name, primitives, 1)),
)
})
// match, matchAll, and search read a RegExp as is and convert anything else to its pattern text.
const withPattern = (args: Array<Value>, op: (pattern: Value) => Value) =>
args[0] instanceof RegExpObj ? op(args[0]) : withPrimitives(ctx, "string", [args[0]], ([text]) => op(text))
const replace = (name: "replace" | "replaceAll") =>
simple(name, 2, (value, args) => {
const pattern = args[0]
const replacer = args[1]
// A RegExp pattern is used as is; a plain one converts to its search string, then a non-callable replacement.
return withPrimitives(
ctx,
"string",
[pattern instanceof RegExpObj ? undefined : pattern, isSupportedCallback(replacer) ? undefined : replacer],
([search, replacement]) => {
if (isSupportedCallback(replacer)) {
return replaceWithCallback(ctx, value, name, [pattern instanceof RegExpObj ? pattern : search, replacer])
}
if (typeofValue(replacer) === "function") {
throw typeError(
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
)
}
const primitives = [search, replacement]
if (pattern instanceof RegExpObj) {
const regex = pattern.regex
const text = str(name, primitives, 1)
if (name === "replaceAll") replaceAllNeedsGlobal(regex)
return name === "replace" ? value.replace(regex, text) : value.replaceAll(regex, text)
}
if (name === "replace") return value.replace(str(name, primitives, 0), str(name, primitives, 1))
return value.replaceAll(str(name, primitives, 0), str(name, primitives, 1))
},
)
})
methods(builtins, builtins.String, [
@@ -185,107 +229,148 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
simple("trimEnd", 0, (value) => value.trimEnd()),
simple("trimRight", 0, (value) => value.trimEnd()),
// Locale/options are deliberately unsupported; comparison uses the host default locale.
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0))),
simple("normalize", 0, (value, args) => {
const form = optStr("normalize", args, 0)
try {
return value.normalize(form)
} catch {
throw rangeError(
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
)
}
}),
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0)), ["string"]),
simple(
"normalize",
0,
(value, args) => {
const form = optStr("normalize", args, 0)
try {
return value.normalize(form)
} catch {
throw rangeError(
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
)
}
},
["string"],
),
simple("split", 2, (value, args) => {
const wrap = (parts: Array<string>) => new Arr(builtins.Array, parts)
// Native: an undefined separator returns the whole string, not a split on "undefined",
// unless the limit truncates to zero.
const requestedLimit = optNum("split", args, 1)
if (args[0] === undefined) {
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
}
const parts =
args[0] instanceof RegExpObj
? value.split(args[0].regex, requestedLimit)
: value.split(str("split", args, 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
checkArrayLength(parts.length)
return wrap(parts)
const separator = args[0]
// A RegExp separator is used as is; the limit converts before a plain separator does, as in the spec.
return withPrimitives(
ctx,
["number", "string"],
[args[1], separator instanceof RegExpObj ? undefined : separator],
([limit, pattern]) => {
const wrap = (parts: Array<string>) => new Arr(builtins.Array, parts)
// Native: an undefined separator returns the whole string, not a split on "undefined",
// unless the limit truncates to zero.
const requestedLimit = args[1] === undefined ? undefined : num("split", [pattern, limit], 1)
if (separator === undefined) {
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
}
const parts =
separator instanceof RegExpObj
? value.split(separator.regex, requestedLimit)
: value.split(str("split", [pattern], 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
checkArrayLength(parts.length)
return wrap(parts)
},
)
}),
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1))),
simple("includes", 1, (value, args) => {
rejectRegex("includes", args)
return value.includes(str("includes", args, 0), optNum("includes", args, 1))
}),
simple("startsWith", 1, (value, args) => {
rejectRegex("startsWith", args)
return value.startsWith(str("startsWith", args, 0), optNum("startsWith", args, 1))
}),
simple("endsWith", 1, (value, args) => {
rejectRegex("endsWith", args)
return value.endsWith(str("endsWith", args, 0), optNum("endsWith", args, 1))
}),
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1))),
simple("lastIndexOf", 1, (value, args) =>
value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1)), [
"number",
"number",
]),
searching("includes", (value, search, position) => value.includes(search, position)),
searching("startsWith", (value, search, position) => value.startsWith(search, position)),
searching("endsWith", (value, search, position) => value.endsWith(search, position)),
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1)), [
"string",
"number",
]),
simple(
"lastIndexOf",
1,
(value, args) => value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
["string", "number"],
),
replace("replace"),
replace("replaceAll"),
simple("match", 1, (value, args) => {
const pattern = toHostRegex(args[0], "match")
const matched = value.match(pattern)
if (matched === null) return null
// Preserve the own `index` and `groups` properties on non-global matches.
if (pattern.global) return new Arr(builtins.Array, [...matched])
return matchToValue(builtins, matched)
}),
simple("matchAll", 1, (value, args) => {
const pattern = toHostRegex(args[0], "matchAll", "g")
if (!pattern.global) {
throw typeError(
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
)
}
const matches: Array<Value> = []
for (const match of value.matchAll(pattern)) {
checkArrayLength(matches.length + 1)
matches.push(matchToValue(builtins, match))
}
return new Arr(builtins.Array, matches)
}),
simple("search", 1, (value, args) => value.search(toHostRegex(args[0], "search"))),
simple("repeat", 1, (value, args) => {
const count = num("repeat", args, 0)
if (!Number.isFinite(count) || count < 0) {
throw rangeError("String.repeat expects a finite non-negative count.")
}
checkStringLength(value.length * count)
return value.repeat(count)
}),
simple("padStart", 1, (value, args) => {
const length = num("padStart", args, 0)
checkStringLength(length)
return value.padStart(length, optStr("padStart", args, 1))
}),
simple("padEnd", 1, (value, args) => {
const length = num("padEnd", args, 0)
checkStringLength(length)
return value.padEnd(length, optStr("padEnd", args, 1))
}),
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0)),
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0)),
simple("substring", 2, (value, args) =>
value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
simple("match", 1, (value, args) =>
withPattern(args, (arg) => {
const regex = toHostRegex(arg, "match")
const matched = value.match(regex)
if (matched === null) return null
// Preserve the own `index` and `groups` properties on non-global matches.
if (regex.global) return new Arr(builtins.Array, [...matched])
return matchToValue(builtins, matched)
}),
),
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1))),
simple("matchAll", 1, (value, args) =>
withPattern(args, (arg) => {
const regex = toHostRegex(arg, "matchAll", "g")
if (!regex.global) {
throw typeError(
`String.matchAll requires a regular expression with the global (g) flag: write /${regex.source}/${regex.flags}g, or use String.match for a single match.`,
)
}
const matches: Array<Value> = []
for (const match of value.matchAll(regex)) {
checkArrayLength(matches.length + 1)
matches.push(matchToValue(builtins, match))
}
return new Arr(builtins.Array, matches)
}),
),
simple("search", 1, (value, args) => withPattern(args, (arg) => value.search(toHostRegex(arg, "search")))),
simple(
"repeat",
1,
(value, args) => {
const count = num("repeat", args, 0)
if (!Number.isFinite(count) || count < 0) {
throw rangeError("String.repeat expects a finite non-negative count.")
}
checkStringLength(value.length * count)
return value.repeat(count)
},
["number"],
),
simple(
"padStart",
1,
(value, args) => {
const length = num("padStart", args, 0)
checkStringLength(length)
return value.padStart(length, optStr("padStart", args, 1))
},
["number", "string"],
),
simple(
"padEnd",
1,
(value, args) => {
const length = num("padEnd", args, 0)
checkStringLength(length)
return value.padEnd(length, optStr("padEnd", args, 1))
},
["number", "string"],
),
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0), ["number"]),
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0), ["number"]),
simple(
"substring",
2,
(value, args) => value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
["number", "number"],
),
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1)), [
"number",
"number",
]),
simple("isWellFormed", 0, (value) => value.isWellFormed()),
simple("toWellFormed", 0, (value) => value.toWellFormed()),
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0)),
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0)),
simple("concat", 1, (value, args) => {
const joined = value.concat(...args.map((_, index) => str("concat", args, index)))
checkStringLength(joined.length)
return joined
}),
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0), ["number"]),
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0), ["number"]),
simple("concat", 1, (value, args) =>
withPrimitives(ctx, "string", args, (parts) => {
const joined = value.concat(...parts.map((_, index) => str("concat", parts, index)))
checkStringLength(joined.length)
return joined
}),
),
])
define(
builtins.String,
+1 -1
View File
@@ -281,7 +281,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "URLSearchParams.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target], args[1])
return undefined
})
},
+12 -7
View File
@@ -1,12 +1,13 @@
import { fn } from "../interpreter/native.js"
import { coerceToNumber, coerceToString, type Native, type Value } from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { withPrimitives } from "../interpreter/callback.js"
export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN"
const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Value => {
export const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>) => {
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
// other coercers match native through the undefined-argument path below.
if (args.length === 0) {
@@ -14,15 +15,19 @@ const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Val
if (name === "String") return ""
}
const raw = args[0]
if (name === "Number") return coerceToNumber(raw)
if (name === "Boolean") return Boolean(raw)
if (name === "isFinite") return Number.isFinite(coerceToNumber(raw))
if (name === "isNaN") return Number.isNaN(coerceToNumber(raw))
if (name === "parseInt") {
return parseInt(coerceToString(raw), coerceToNumber(args[1]))
return withPrimitives(ctx, ["string", "number"], [raw, args[1]], ([text, radix]) =>
parseInt(coerceToString(text), coerceToNumber(radix)),
)
}
if (name === "parseFloat") return parseFloat(coerceToString(raw))
return coerceToString(raw)
return withPrimitives(ctx, name === "String" || name === "parseFloat" ? "string" : "number", [raw], ([value]) => {
if (name === "Number") return coerceToNumber(value)
if (name === "isFinite") return Number.isFinite(coerceToNumber(value))
if (name === "isNaN") return Number.isNaN(coerceToNumber(value))
if (name === "parseFloat") return parseFloat(coerceToString(value))
return coerceToString(value)
})
}
/** A global coercion function such as `Number` or `parseInt`. */
+5 -1
View File
@@ -16,6 +16,8 @@ import {
Obj,
RegExpObj,
SetObj,
WeakMapObj,
WeakSetObj,
coerceToString,
type Value,
} from "../interpreter/objects.js"
@@ -84,7 +86,9 @@ export const structuredCloneGlobal = <R>(ctx: Interpreter<R>) =>
if (hasOwn(value, "cause")) define(copy, "cause", clone(getOwn(value, "cause")), hidden)
return copy
}
if (isRuntimeReference(value)) throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
if (isRuntimeReference(value) || value instanceof WeakMapObj || value instanceof WeakSetObj) {
throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
}
const copy = remember(
value instanceof Arr ? new Arr(builtins.Array, new Array(value.items.length)) : new Obj(builtins.Object),
)
@@ -210,11 +210,14 @@ describe("Test262 JSON.stringify replacer adaptations", () => {
})
describe("CodeMode JSON callback boundaries", () => {
test("this remains unsupported rather than exposing callback holders", async () => {
const result = await Effect.runPromise(
CodeMode.execute({ code: `return JSON.parse("1", function (key, item) { return this })`, tools: {} }),
)
expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } })
test("revivers and replacers see the holder as this", async () => {
expect(
await value(`
const revived = JSON.parse('{"a":{"b":1}}', function (key, item) { return key === "b" ? this.b + 1 : item })
const text = JSON.stringify({ a: 1, b: 2 }, function (key, item) { return key === "a" ? this.b : item })
return [revived, text]
`),
).toEqual([{ a: { b: 2 } }, '{"a":2,"b":2}'])
})
test("prototype-named keys parse as own data and reach the reviver", async () => {
@@ -77,6 +77,6 @@ describe("new on a non-constructible callee", () => {
expect(failure.message).toStartWith(
"SyntaxError: Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
)
expect(failure.message).toContain("Unsupported: classes, this, getters/setters, BigInt, and custom Symbols.")
expect(failure.message).toContain("Unsupported: classes, getters/setters, BigInt, and custom Symbols.")
})
})
+523
View File
@@ -1461,3 +1461,526 @@ describe("structuredClone", () => {
expect((await error(`structuredClone()`)).message).toContain("structuredClone requires 1 argument")
})
})
describe("error constructor prototype chain", () => {
test("derived error constructors extend Error and inherit its statics", async () => {
expect(
await value(`
const derived = [TypeError, RangeError, SyntaxError, ReferenceError, EvalError, URIError, AggregateError]
return [
derived.every((ctor) => Object.getPrototypeOf(ctor) === Error),
Object.getPrototypeOf(Error) === Function.prototype,
TypeError.isError(new RangeError("x")),
new TypeError("x") instanceof Error,
]
`),
).toEqual([true, true, true, true])
})
})
describe("this, arguments, and Function.prototype.call/apply/bind", () => {
test("this is the call receiver for non-arrow functions and lexical for arrows", async () => {
expect(
await value(`
const o = { n: 1, m() { return this.n }, a() { return (() => this.n)() }, bare() { return this } }
const detached = o.bare
function f() { return this }
return [o.m(), o["m"](), o?.m(), (o.m)(), o.a(), o.bare() === o, detached(), f(), (0, o.bare)(), this, (() => this)()]
`),
).toEqual([1, 1, 1, 1, 1, true, null, null, null, null, null])
})
test("this reaches generator and async methods and plain-function callbacks", async () => {
expect(
await value(`
const o = { n: 2, *g() { yield this.n }, async m() { return this.n }, xs: [1, 2], go() { return this.xs.map(function (x) { return [x, this] }) } }
return [[...o.g()], await o.m(), o.go()]
`),
).toEqual([
[2],
2,
[
[1, null],
[2, null],
],
])
})
test("arguments is an unmapped array-like that arrows and parameters interact with as in JS", async () => {
expect(
await value(`
function f(a) { arguments[0] = 9; return [arguments.length, arguments[1], a, [...arguments], Array.isArray(arguments), JSON.stringify(arguments), typeof arguments.map, (() => arguments[1])()] }
function shadow(arguments) { return arguments }
function hoisted() { var arguments; return arguments.length }
let outer
try { outer = arguments } catch (error) { outer = error.name }
return [f(1, 2), shadow(7), hoisted(1, 2, 3), outer]
`),
).toEqual([[2, 2, 1, [9, 2], false, '{"0":9,"1":2}', "undefined", 2], 7, 3, "ReferenceError"])
})
test("call, apply, and bind set this and arguments on program functions and built-ins", async () => {
expect(
await value(`
function f(a, b, c) { return [this, a, b, c] }
const g = f.bind({ k: 1 }, "A")
const arr = [1]
Array.prototype.push.call(arr, 2, 3)
return [
f.call("t", 1, 2),
f.apply({ k: 2 }, [1, 2]),
f.apply(null, { length: 2, 0: "x", 1: "y" }),
f.apply(null).length,
g("B", "C"), g.name, g.length,
f.bind(1).bind(2)()[0],
arr,
Math.max.apply(null, [1, 5, 3]),
Math.max.bind(null, 10)(3),
[1, 2].map(f.bind(null, 0)).map((r) => r[1]),
]
`),
).toEqual([
["t", 1, 2, null],
[{ k: 2 }, 1, 2, null],
[null, "x", "y", null],
4,
[{ k: 1 }, "A", "B", "C"],
"bound f",
2,
1,
[1, 2, 3],
5,
10,
[0, 0],
])
})
test("call and apply reject non-callable receivers and non-array-like argument lists", async () => {
expect((await error(`Function.prototype.call.call(1)`)).message).toContain(
"Function.prototype.call called on incompatible receiver",
)
expect((await error(`(() => 1).apply(null, 5)`)).message).toContain("expects an array-like argument list")
expect((await error(`(() => 1).apply(null, { length: 1e9 })`)).message).toContain("Invalid array length")
expect(
await value(
`function f() { return arguments.length } return [f.apply(null, { length: -5 }), f.apply(null, { length: "2" })]`,
),
).toEqual([0, 2])
expect((await error(`function f(n) { return f.call(null, n + 1) } f(0)`)).message).toContain(
"Maximum call stack size exceeded",
)
})
})
describe("ToPrimitive: operators and conversions honor program valueOf and toString", () => {
test("program-installed valueOf and toString on opaque values are ignored at every site", async () => {
expect(
await value(`
const f = () => 1
f.toString = () => "custom"
f.valueOf = () => 5
return [String(f), \`\${f}\`, [f].join(), new Error(f).message, isNaN(Number(f)), isNaN(Math.abs(f))]
`),
).toEqual(["[object Function]", "[object Function]", "[object Function]", "[object Function]", true, true])
})
test("== converts an object facing a non-nullish primitive through its own valueOf", async () => {
expect(
await value(`
const one = { valueOf() { return 1 } }
return [one == 1, 1 == one, one == true, one == "1", one == null, one == one, one == { valueOf() { return 1 } }, [1] == 1]
`),
).toEqual([true, true, true, true, false, true, false, true])
expect((await error(`(() => 1) == 1`)).message).toContain("Binary operators require data values")
})
test("operators, unary, template literals, and conversion functions use the object's own methods", async () => {
expect(
await value(`
const money = { valueOf() { return 7 } }
return [money * 2, money + 1, money + "", -money, +money, ~money, money < 8, money ** 2, money | 8,
Number(money), Math.max(money, 1), \`\${money}\`, String(money), isNaN(money), isFinite(money),
parseInt({ toString() { return "42px" } }), parseInt("ff", { valueOf() { return 16 } }),
Number.parseFloat({ toString() { return "1.5" } })]
`),
).toEqual([
14,
8,
"7",
-7,
7,
-8,
true,
49,
15,
7,
7,
"[object Object]",
"[object Object]",
false,
true,
42,
255,
1.5,
])
})
test("the hint picks the method: + and Number prefer valueOf, template literals and String prefer toString", async () => {
expect(
await value(`
const both = { valueOf() { return 1 }, toString() { return "s" } }
return [both + "", \`\${both}\`, String(both), both * 2, new Error(both).message, [both].join(), [both, 2] + ""]
`),
).toEqual(["1", "s", "s", 2, "s", "s", "s,2"])
})
test("operands convert left then right, and a throwing valueOf surfaces as the program error", async () => {
expect(
await value(`
const order = []
const a = { valueOf() { order.push("a"); return 1 } }, b = { valueOf() { order.push("b"); return 2 } }
a + b; a < b; a - b
return order
`),
).toEqual(["a", "b", "a", "b", "a", "b"])
expect(
await value(`
const bad = { valueOf() { throw new RangeError("nope") } }
const names = []
try { bad + 1 } catch (e) { names.push(e.name) }
try { Number(bad) } catch (e) { names.push(e.name) }
try { Math.abs(bad) } catch (e) { names.push(e.name) }
return names
`),
).toEqual(["RangeError", "RangeError", "RangeError"])
})
test("arrays keep their built-in join form unless the program replaces toString", async () => {
expect(
await value(`
const arr = [1, 2]
const before = [arr + "", [] + [], [1, , 3].join("-"), [1, { toString() { return "q" } }].join("-")]
arr.toString = () => "x"
return [...before, arr + "", \`\${arr}\`, String(arr)]
`),
).toEqual(["1,2", "", "1--3", "1-q", "x", "x", "x"])
})
test("update and compound assignment convert the current value", async () => {
expect(
await value(`
let x = { valueOf() { return 5 } }
const o = { n: { valueOf() { return 4 } } }
const after = x++
o.n += 1
o.n++
let s = { valueOf() { return 2 } }
s *= 3
return [after, x, o.n, s]
`),
).toEqual([5, 6, 6, 6])
})
test("functions and other opaque values still reject arithmetic, and an object without a primitive form throws", async () => {
expect((await error(`const f = () => 1; return f + 1`)).message).toContain("Binary operators require data values")
expect((await error(`return -(() => 1)`)).message).toContain("Unary operators require data values")
const failure = await error(`return { valueOf() { return {} }, toString() { return [] } } + 1`)
expect(failure.message).toContain("Cannot convert object to primitive value")
})
})
describe("object destructuring from primitives", () => {
test("reads through the primitive's prototype like member access", async () => {
expect(
await value(`
const { length, 0: first, toUpperCase } = "abc"
const { toFixed } = 1.5
const {} = true
const { 0: a, ...rest } = "xyz"
const { ...none } = 42
let n
;({ length: n } = "hello")
return [length, first, toUpperCase.call("q"), toFixed.call(2.345, 1), a, rest, none, n]
`),
).toEqual([3, "a", "Q", "2.3", "x", { 1: "y", 2: "z" }, {}, 5])
})
test("only null and undefined sources throw", async () => {
expect((await error(`const { a } = null`)).message).toContain("Cannot destructure null as it is null")
expect((await error(`const {} = undefined`)).message).toContain("Cannot destructure undefined")
expect((await error(`let a; ({ a } = undefined)`)).message).toContain("Cannot destructure undefined")
})
})
describe("Date components convert through ToPrimitive", () => {
test("construction and Date.UTC ask each of the first seven arguments in order", async () => {
expect(
await value(`
const seen = []
const part = (n) => ({ valueOf() { seen.push(n); return n } })
const time = new Date(part(2024), part(1), part(2), part(3), part(4), part(5), part(6), part(99)).getTime()
const utc = Date.UTC(2024, { valueOf() { return 0 } }, 15)
return [seen, time === new Date(2024, 1, 2, 3, 4, 5, 6).getTime(), utc === Date.UTC(2024, 0, 15)]
`),
).toEqual([[2024, 1, 2, 3, 4, 5, 6], true, true])
expect((await error(`new Date(2024, { valueOf() { throw new RangeError("boom") } })`)).message).toContain("boom")
})
test("setters on an invalid Date answer NaN without overwriting a time set during coercion", async () => {
expect(
await value(`
const d = new Date(NaN)
const result = d.setDate({ valueOf() { d.setTime(0); return 1 } })
const y = new Date(NaN)
return [Number.isNaN(result), d.getTime(), y.setFullYear(2020) === Date.UTC(2020, 0, 1) - y.getTimezoneOffset() * 60000]
`),
).toEqual([true, 0, true])
})
})
describe("iteration callbacks receive thisArg", () => {
test("Array, Array.from, Map, Set, URLSearchParams, Headers, and Uint8Array pass it as this", async () => {
expect(
await value(`
const c = { n: 0 }
const count = function () { this.n++ }
;[1, 2].forEach(count, c)
;[1].map(count, c)
;[1].filter(count, c)
;[1].find(count, c)
;[1].findIndex(count, c)
;[1].findLast(count, c)
;[1].findLastIndex(count, c)
;[1].some(count, c)
;[1].every(count, c)
;[1].flatMap(count, c)
Array.from([1], count, c)
Array.from({ length: 1 }, count, c)
new Map([[1, 1]]).forEach(count, c)
new Set([1]).forEach(count, c)
new URLSearchParams("a=1").forEach(count, c)
new Headers({ a: "1" }).forEach(count, c)
new Uint8Array([1]).forEach(count, c)
return c.n
`),
).toBe(18)
expect(await value(`return [1, 2].map(function (x) { return x + this.v }, { v: 10 })`)).toEqual([11, 12])
})
test("arrows keep their lexical this, reduce takes an initial value instead, and opaque values are only bound", async () => {
expect(await value(`return [1].map(() => typeof this, { v: 1 })`)).toEqual(["undefined"])
expect(
await value(`return [1, 2].reduce(function (a, b) { return a + b + (this === undefined ? 0 : 100) }, 0)`),
).toBe(3)
expect(
await value(`
let seen
;[1].forEach(function () { seen = this }, tools.nowhere)
return typeof seen
`),
).toBe("function")
})
})
describe("computed property keys convert through the object's own toString", () => {
test("reads, writes, compound assignment, in, delete, literals, and destructuring share one conversion", async () => {
expect(
await value(`
const key = { toString() { return "id" } }
const o = {}
o[key] = 1
o[key] += 1
const literal = { [key]: "lit" }
const had = key in o
delete literal[key]
return [o.id, had, (({ [key]: v }) => v)(o), literal, o[[1, 2]] === undefined]
`),
).toEqual([2, true, 2, {}, true])
expect(
await value(`
const seen = []
const base = { x: 1 }
base[{ toString() { seen.push(1); return "" } }] ^= 0
base[{ toString() { seen.push(2); return "x" } }]++
return [seen, base[""], base.x]
`),
).toEqual([[1, 2], 0, 2])
})
test("valueOf is the fallback, a symbol result stays a symbol, and conversion failures surface", async () => {
expect(
await value(`
const o = { 7: "seven" }
const sym = { toString() { return Symbol.iterator } }
o[sym] = 1
return [o[{ valueOf() { return 7 }, toString: undefined }], typeof o[Symbol.iterator], Object.keys(o)]
`),
).toEqual(["seven", "number", ["7"]])
expect((await error(`({})[{ toString() { throw new RangeError("bad key") } }]`)).message).toContain("bad key")
expect((await error(`({})[{ toString() { return {} }, valueOf() { return {} } }]`)).message).toContain(
"Cannot convert object to primitive value",
)
expect((await error(`const key = { toString() { return "a" } }; key in 5`)).message).toContain(
"requires a data object on the right-hand side",
)
})
test("a nullish base throws before the key converts, as ToObject precedes ToPropertyKey", async () => {
const failure = await error(`const base = null; base[{ toString() { throw new RangeError("key evaluated") } }]`)
expect(failure.message).toContain("Cannot read properties of null")
})
test("opaque values keep their built-in key form and a tool reference toString is never called", async () => {
expect(
await value(`
const o = { "[object Function]": 1, "[object Promise]": 2 }
return [o[() => 1], o[Promise.resolve("k")]]
`),
).toEqual([1, 2])
expect((await error(`({})[{ toString: tools.nowhere }] = 1`)).message).toContain(
"Cannot convert object to primitive value",
)
})
})
describe("String and Number method arguments convert through ToPrimitive", () => {
test("string positions use the string hint and numeric positions the number hint", async () => {
expect(
await value(`
const s = { toString() { return "b" } }
const n = { valueOf() { return 1 } }
return [
"abc".indexOf(s), "abc".lastIndexOf(s), "abc".includes(s), "abc".startsWith(s, n), "abc".endsWith(s, 2),
"abc".charAt(n), "abc".at({ valueOf() { return -1 } }), "abc".slice(n), "abc".substring(n, 2),
"abc".charCodeAt(n), "a".padStart({ valueOf() { return 3 } }, s), "x".padEnd(3, s), "ab".repeat({ valueOf() { return 2 } }),
"a".concat(s, { valueOf() { return 1 }, toString() { return "T" } }), "b".localeCompare(s),
(1.005).toFixed({ valueOf() { return 2 } }), (255).toString({ valueOf() { return 16 } }),
(1234.5678).toPrecision({ valueOf() { return 6 } }), (12345).toExponential({ valueOf() { return 2 } }),
]
`),
).toEqual([
1,
1,
true,
true,
true,
"b",
"c",
"bc",
"b",
98,
"bba",
"xbb",
"abab",
"abT",
0,
"1.00",
"ff",
"1234.57",
"1.23e+4",
])
})
test("split, replace, match, and search convert a plain pattern but keep a RegExp as is", async () => {
expect(
await value(`
const s = { toString() { return "b" } }
return [
"abc".split(s), "abc".split(/b/, { valueOf() { return 1 } }), "abc".split(undefined, { valueOf() { return undefined } }),
"abc".replace(s, "X"), "abc".replace(/b/, { toString() { return "R" } }), "abc".replaceAll(s, s),
"abc".replace(s, (m) => m.toUpperCase()), "abc".match(s)[0], "abcb".matchAll(s).length, "abc".search(s),
]
`),
).toEqual([["a", "c"], ["a"], [], "aXc", "aRc", "abc", "aBc", "b", 2, 1])
expect((await error(`"abc".includes(/b/)`)).message).toContain("cannot take a regular expression")
})
test("the receiver converts first, then each consumed argument, in spec order; extra arguments are untouched", async () => {
expect(
await value(`
const log = []
const observer = (name, string, number) => ({
toString() { log.push("toString:" + name); return string },
valueOf() { log.push("valueOf:" + name); return number },
})
const padded = String.prototype.padStart.call(observer("receiver", {}, "abc"), observer("maxLength", 11, {}), observer("fillString", {}, "def"))
const extra = "abc".indexOf("b", 1, { valueOf() { throw new Error("extra argument converted") } })
return [padded, log, extra, String.prototype.trim.call({ toString() { return " abc " } })]
`),
).toEqual([
"defdefdeabc",
[
"toString:receiver",
"valueOf:receiver",
"valueOf:maxLength",
"toString:maxLength",
"toString:fillString",
"valueOf:fillString",
],
1,
"abc",
])
})
test("conversion failures surface and opaque arguments still reject", async () => {
expect((await error(`"abc".indexOf({ toString() { throw new RangeError("intostr") } })`)).message).toContain(
"intostr",
)
expect((await error(`(1).toString({ valueOf() { throw new SyntaxError("poison") } })`)).message).toContain("poison")
expect((await error(`(1).toFixed({ toString() { return {} }, valueOf() { return {} } })`)).message).toContain(
"Cannot convert object to primitive value",
)
expect((await error(`"abc".indexOf(tools.nowhere)`)).message).toContain("expects argument 1 to be a data value")
expect((await error(`"abc".indexOf(Promise.resolve("b"))`)).message).toContain(
"expects argument 1 to be a data value",
)
})
})
describe("WeakMap and WeakSet", () => {
test("hold program objects by identity and answer like JS for non-object keys", async () => {
expect(
await value(`
const k = {}
const f = () => 1
const wm = new WeakMap([[k, 1]])
const ws = new WeakSet([k])
return [
wm.set(f, "fn") === wm, wm.get(k), wm.get(f), wm.has({}), wm.get(1), wm.has(1), wm.delete("s"),
wm.getOrInsert(k, 9), wm.getOrInsertComputed({}, (key) => typeof key),
ws.add(f) === ws, ws.has(k), ws.has(f), ws.has(1), ws.delete(k), ws.has(k),
String(wm), wm.size, "clear" in wm, Symbol.iterator in ws, JSON.stringify(wm),
]
`),
).toEqual([
true,
1,
"fn",
false,
null,
false,
false,
1,
"object",
true,
true,
true,
false,
true,
false,
"[object WeakMap]",
null,
false,
false,
"{}",
])
})
test("reject primitive keys, plain calls, bad receivers, and cloning", async () => {
expect((await error(`new WeakMap().set(1, 1)`)).message).toContain("Invalid value used as weak map key")
expect((await error(`new WeakSet([1])`)).message).toContain("Invalid value used in weak set")
expect((await error(`WeakMap()`)).message).toContain("new")
expect((await error(`WeakMap.prototype.get.call(new Map(), {})`)).message).toContain("incompatible receiver")
expect((await error(`structuredClone(new WeakSet())`)).message).toContain("DataCloneError")
})
})
+3 -1
View File
@@ -1416,7 +1416,9 @@ describe("built-in iterators", () => {
const logged = await run(`console.log([1].keys()); return null`)
expect(logged.logs?.[0]).toBe("[opaque reference]")
expect((await error(`return [1].keys() + ""`)).message).toContain("Binary operators require data values")
expect((await error(`return [1].keys().next.call({})`)).message).toContain("is not a function")
expect((await error(`return [1].keys().next.call({})`)).message).toContain(
"Iterator.prototype.next called on incompatible receiver a data object",
)
expect((await error(`const it = [1].keys(); const next = it.next; return next()`)).message).toContain(
"Iterator.prototype.next called on incompatible receiver undefined",
)
+6 -4
View File
@@ -12,7 +12,8 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
## Layout
- `manifest.json` — the pinned upstream revision, which upstream directories are copied, and what is left out.
- `manifest.json` — the pinned upstream revision, which upstream directories are copied (every `built-ins` and
`language` directory, about 14,900 files after filtering), and what is left out.
- `built-ins/`, `language/` — the copied files, mirroring upstream `test/`; gitignored.
- `skipped.txt` — vendored files that fail on a known interpreter gap, one `path # reason` per line. They are
skipped, and each gap is listed as unchecked in `interpreter-support.md`.
@@ -24,9 +25,10 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
`script/sync-test262.ts` skips a file when its frontmatter declares a `flags`, `features`, or `includes` value the
manifest marks unsupported, or when its code matches one of the manifest's `boundaries` patterns. The sync checks the
checkout is at the pinned revision, so every machine runs the same 4595 files. Boundaries are
intentional limits of the interpreter, not compatibility work: classes, `this`, `arguments`, prototype objects,
property descriptors, accessors, boxed primitives, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
checkout is at the pinned revision, so every machine runs the same files. Boundaries are
intentional limits of the interpreter, not compatibility work: classes, prototype objects, property descriptors,
accessors, boxed primitives, typed arrays and buffers, `WeakRef` and `FinalizationRegistry`, `Reflect` and `Proxy`, sloppy mode, `eval`,
`Symbol()`, and the `$262` host API. If one
of those decisions changes, delete its entry and re-sync; the tests are upstream, not lost.
## Commands

Some files were not shown because too many files have changed in this diff Show More