Compare commits

...
Author SHA1 Message Date
Aiden Cline b8c48e7f7d refactor(codemode): make functions program objects with name and length 2026-09-11 13:43:38 -05:00
Aiden Cline a555f24ded refactor: flatten provider settings across core and the AI package (#48429) 2026-09-11 13:37:24 -05:00
Aiden Cline 10ae321bf3 refactor(codemode): give programs their own objects and arrays (#48527) 2026-09-11 13:36:35 -05:00
Aiden Cline 6e8ff0c2b7 refactor(core): remove dead AISDK hooks (#48523) 2026-09-11 12:41:20 -05:00
Aiden Cline 923bda07a2 feat(ai): always send interleaved thinking beta for Anthropic (#48521) 2026-09-11 12:29:18 -05:00
Aiden Cline 4c34ee5eab test(codemode): run test262 files verbatim from a local checkout (#48446) 2026-09-11 10:40:02 -05:00
Shoubhit Dash 2df00955cb fix(cli): preserve ACP reasoning boundaries and effort selections (#48493) 2026-09-11 18:46:54 +05:30
opencode-agent[bot]andnexxeln e0deffa083 docs(plugin): fix Effect system part example (#48492)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-11 18:12:11 +05:30
Wu Shuwen 4e97b78d98 docs: fix v2 plugin system example (#48452) 2026-09-11 18:12:01 +05:30
Brendan Allan 6bb4b35399 fix(app): preserve loopback server host (#44296) 2026-09-11 06:41:38 +00:00
Brendan Allan 0ce1383030 fix(app): preserve file search results while loading (#43832) 2026-09-11 06:13:32 +00:00
Brendan AllanandBrendonovich 010cd6131e fix(app): merge adjacent file-changing tools (#44977)
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-09-11 05:58:02 +00:00
Brendan Allan c2348f8f69 fix(app): coordinate notifications across windows (#44613) 2026-09-11 05:28:13 +00:00
Aiden Cline 71317ec7c9 fix(core): report provider initialization failures (#48433) 2026-09-11 00:26:55 -05:00
opencode-agent[bot]andBrendonovich 4261fe749d fix(desktop): repair SSH server updates (#48442)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-09-11 13:24:38 +08:00
Aiden Cline 9dd7149e75 feat(core): append comment guidance to Anthropic system prompt (#48444) 2026-09-11 00:05:05 -05:00
Brendan Allan 0c0a431c9f fix(ui): make comment cancel action ghost (#46742) 2026-09-11 13:03:49 +08:00
Brendan Allan 3368e049d2 fix(app): refine home row hover actions (#47270) 2026-09-11 04:39:26 +00:00
Aiden Cline f0b5da1c11 feat(ai): type provider configuration errors (#48440) 2026-09-10 23:29:55 -05:00
Aiden Cline 15c525dcfb fix(merman): support & node groups in flowchart statements (#48425) 2026-09-10 22:24:59 -05:00
James Long 4d12e01824 refactor(tui): project production subgroups through tree engine (#48399) 2026-09-10 23:15:58 -04:00
James Long 70afbac80c docs(www): add core CLI theme concepts (#48426) 2026-09-10 23:13:53 -04:00
James Long 1c723c56fa feat(tui): add recursive grouping tree (#48394) 2026-09-10 21:40:23 -04:00
Aiden Cline 181428a2f3 fix(codemode): use Bun's wording for the missing atob/btoa argument (#48381) 2026-09-10 20:08:36 -05:00
Dax Raad 9b1891fb7e docs(cli): expand provider connections 2026-09-10 20:43:17 -04:00
Dax Raad d4bf78b348 docs: add websearch guides 2026-09-10 20:30:58 -04:00
Dax Raad d4ceffe787 docs: refresh v2 guides 2026-09-10 20:12:08 -04:00
Aiden Cline 872e38055e fix(ai): normalize flat Responses stream errors (#48376) 2026-09-10 18:23:32 -05:00
174 changed files with 6879 additions and 3807 deletions
+2 -1
View File
@@ -122,13 +122,14 @@ Keep provider facades small and explicit:
### Provider Package Entrypoints
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are one flat serializable object: the connection keys the entrypoint declares (`apiKey`, `baseURL`, `region`, …), the common `headers` and `body` overlays, and the protocol's request options (`reasoningEffort`, `thinking`, …) side by side. Each entrypoint destructures its own connection keys and passes the rest to the route as `providerOptions`; there is no nested `providerOptions` at the entrypoint.
```ts
import { model } from "@opencode/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
reasoningEffort: "high",
})
```
@@ -1708,7 +1708,10 @@ export const transport = <
}
function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">) {
const betas: string[] = []
// Always request interleaved thinking. The API accepts the header on any
// model and ignores it where unsupported, while manual-thinking models need
// it for thinking between tool calls.
const betas: string[] = ["interleaved-thinking-2025-05-14"]
const requestsCompaction = (body.context_management?.edits.length ?? 0) > 0
const replaysCompaction = body.messages.some((message) =>
message.content.some((block) => block.type === "compaction"),
+14 -4
View File
@@ -1,4 +1,4 @@
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Schema, SchemaGetter } from "effect"
import type { Content } from "@opencode/schema/tool"
import { HttpTransport } from "../route/transport/index.js"
import { Protocol } from "../route/protocol.js"
@@ -325,9 +325,8 @@ export const StreamItem = Schema.StructWithRest(
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
// The Responses schema puts streaming error details at the top level and
// response failures under `response.error`. WebSocket failures use an
// event-level `error` envelope, so accept all three shapes here.
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
@@ -401,6 +400,17 @@ export const Event = Schema.StructWithRest(
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
).pipe(
Schema.decode({
decode: SchemaGetter.transform((event) => {
if (event.type !== "error" || event.error != null) return event
const { code, message, param, ...rest } = event
if (code === undefined && message === undefined && param === undefined) return event
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
return { ...rest, error: { code, message, param } }
}),
encode: SchemaGetter.passthrough(),
}),
)
export type Event = Schema.Schema.Type<typeof Event>
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
+4
View File
@@ -1,6 +1,10 @@
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
import type { CompactionOperations } from "./route/client.js"
/**
* Flat, serializable settings for `model(modelID, settings)`. Each entrypoint declares the connection keys it
* reads; every other key is a request option for the route's protocol.
*/
export interface Settings extends Readonly<Record<string, unknown>> {
readonly baseURL?: string
readonly headers?: Readonly<Record<string, string>>
+16 -6
View File
@@ -1,3 +1,4 @@
import { Struct } from "effect"
import type { ProviderPackage } from "../provider-package.js"
import { AlibabaChat } from "../protocols/alibaba-chat.js"
import { AlibabaMessages } from "../protocols/alibaba-messages.js"
@@ -6,7 +7,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("alibaba")
@@ -34,9 +35,9 @@ export type Config = Location &
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export type Settings<Options = ChatOptionsInput> = Location &
ProviderPackage.Settings & {
ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly providerOptions?: Options
}
const hosts = new Map<string, string>([
@@ -82,8 +83,13 @@ export const configure = (input: Config) => {
? hosts.get(region)
: `${workspaceID}.${region}.maas.aliyuncs.com`
if (baseURL === undefined) {
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
if (region === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
if (host === undefined)
throw new ProviderConfigurationError({
provider: id,
message: `Alibaba region ${region} requires workspaceID or baseURL`,
})
}
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
@@ -115,7 +121,11 @@ export const responsesModel: ProviderPackage.Definition<
function fromSettings(input: Settings<Config["providerOptions"]>) {
const { body, ...rest } = input
return configure({ ...rest, http: body === undefined ? undefined : { body } })
return configure({
...rest,
http: body === undefined ? undefined : { body },
providerOptions: Struct.omit(rest, ["apiKey", "baseURL", "headers", "region", "workspaceID"]),
})
}
export const webSearch = () => hostedTool("web_search", "Search the web with Alibaba's hosted search tool.")
@@ -4,7 +4,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("amazon-bedrock")
@@ -23,16 +23,16 @@ export type Config = RouteDefaultsInput & {
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly profile?: string
readonly region?: string
readonly topP?: number
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly auth?: "bearer" | "sigv4"
readonly baseURL?: string
readonly credentials?: Credentials
readonly profile?: string
readonly region?: string
readonly topP?: number
}
const responsesRoute = Route.make({
id: "bedrock-mantle-responses",
@@ -79,9 +79,12 @@ const defaults = (input: Config) => {
export const configure = (input: Config = {}) => {
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
throw new ProviderConfigurationError({
provider: id,
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
})
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
@@ -105,18 +108,29 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const fromSettings = (settings: Settings) =>
const fromSettings = ({
apiKey,
auth,
baseURL,
body,
credentials,
headers,
profile,
region,
topP,
...providerOptions
}: Settings) =>
configure({
apiKey: settings.apiKey,
auth: settings.auth,
baseURL: settings.baseURL,
credentials: settings.credentials,
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
profile: settings.profile,
providerOptions: settings.providerOptions,
region: settings.region,
apiKey,
auth,
baseURL,
credentials,
generation: topP === undefined ? undefined : { topP },
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
profile,
providerOptions,
region,
})
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
+4 -3
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
@@ -39,8 +39,9 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => {
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
if (auth === "sigv4" && apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
const resolvedRegion = BedrockAuth.resolveRegion(input)
return BedrockConverse.route.with({
...rest,
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
@@ -19,13 +19,13 @@ export type Config = RouteDefaultsInput &
}
export type Settings = ProviderPackage.Settings &
AnthropicMessages.ProviderOptionsInput &
(
| { readonly apiKey?: string; readonly authToken?: never }
| { readonly apiKey?: never; readonly authToken?: string }
) & {
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export const routes = [AnthropicMessages.route]
@@ -36,8 +36,12 @@ const auth = (input: ProviderAuthOption<"optional">) => {
}
export const configure = (input: Config) => {
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
const provider = input.provider ?? "anthropic-compatible"
if (!input.baseURL)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: "Anthropic-compatible providers require a baseURL",
})
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = AnthropicMessages.route.with({
...rest,
@@ -59,17 +63,20 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, authToken, baseURL, body, headers, provider, ...providerOptions },
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
if (apiKey !== undefined && authToken !== undefined)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider ?? id),
message: "Anthropic-compatible apiKey cannot be combined with authToken",
})
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
provider,
providerOptions,
}).model(modelID)
}
+13 -10
View File
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -21,12 +21,12 @@ export type Config = RouteDefaultsInput &
}
export type Settings = ProviderPackage.Settings &
AnthropicMessages.ProviderOptionsInput &
(
| { readonly apiKey?: string; readonly authToken?: never }
| { readonly apiKey?: never; readonly authToken?: string }
) & {
readonly baseURL?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
const auth = (options: ProviderAuthOption<"optional">) => {
@@ -54,15 +54,18 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, authToken, baseURL, body, headers, ...providerOptions },
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new Error("Anthropic apiKey cannot be combined with authToken")
if (apiKey !== undefined && authToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Anthropic apiKey cannot be combined with authToken",
})
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
}
+23 -13
View File
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { ProviderShared } from "../protocols/shared.js"
@@ -28,12 +28,12 @@ export type LanguageModelOptions = AzureURL &
export type Config = LanguageModelOptions
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput &
AzureURL & {
readonly apiKey?: string
readonly apiVersion?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly useDeploymentBasedUrls?: boolean
readonly providerOptions?: OpenAIProviderOptionsInput
}
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
@@ -151,19 +151,29 @@ export const provider = {
configure,
}
const config = (settings: Settings): Config => {
const config = ({
apiKey,
apiVersion,
baseURL,
body,
headers,
queryParams,
resourceName,
useDeploymentBasedUrls,
...providerOptions
}: Settings): Config => {
const common = {
apiKey: settings.apiKey,
apiVersion: settings.apiVersion,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
apiKey,
apiVersion,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
queryParams: queryParams === undefined ? undefined : { ...queryParams },
useDeploymentBasedUrls,
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
throw new Error("Azure requires resourceName or baseURL")
if (baseURL !== undefined) return { ...common, baseURL }
if (resourceName !== undefined) return { ...common, resourceName }
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
}
export const responsesModel: ProviderPackage.Definition<
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "baseten-chat",
@@ -48,13 +48,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Baseten from "./baseten.js"
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "cerebras-chat",
@@ -52,11 +52,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-ai-gateway")
@@ -27,15 +27,19 @@ export type LanguageModelOptions = GatewayURL &
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput &
GatewayURL & {
readonly apiKey?: string
readonly gatewayApiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const baseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
})
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
}
@@ -85,14 +89,25 @@ export const configure = (input: LanguageModelOptions) => {
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
gatewayApiKey: settings.gatewayApiKey,
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const {
accountId: _,
apiKey,
baseURL: _url,
body,
gatewayApiKey,
gatewayId: _id,
headers,
...providerOptions
} = settings
return configure({
apiKey,
gatewayApiKey,
baseURL: baseURL(settings),
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
}
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-workers-ai")
@@ -21,14 +21,18 @@ export type LanguageModelOptions = WorkersAIURL &
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput &
WorkersAIURL & {
readonly apiKey?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export const baseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
})
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
}
@@ -59,13 +63,15 @@ export const configure = (input: LanguageModelOptions) => {
export const provider = { id, configure }
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
configure({
apiKey: settings.apiKey,
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const { accountId: _, apiKey, baseURL: _url, body, headers, ...providerOptions } = settings
return configure({
apiKey,
baseURL: baseURL(settings),
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
}
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "deepinfra-chat",
@@ -55,11 +55,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "deepseek-chat",
@@ -52,13 +52,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as DeepSeek from "./deepseek.js"
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "fireworks-chat",
@@ -48,13 +48,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Fireworks from "./fireworks.js"
+24 -19
View File
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
const route = Route.make({
id: "google-vertex-chat",
@@ -37,7 +37,8 @@ const route = Route.make({
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
const {
accessToken: _accessToken,
auth: _auth,
@@ -73,15 +74,19 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
accessToken,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
}).model(modelID)
}
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
@@ -25,14 +25,14 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
AnthropicMessages.ProviderOptionsInput & {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
const route = Route.make({
id: "google-vertex-messages",
@@ -67,7 +67,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new Error("Google Vertex Messages does not support API keys")
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
const {
accessToken: _accessToken,
auth: _auth,
@@ -105,16 +105,17 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
modelID,
settings,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
if (apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
accessToken,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
}).model(modelID)
}
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenResponsesProviderOptionsInput & {
readonly accessToken?: string
readonly apiKey?: never
readonly baseURL?: string
readonly location?: string
readonly project?: string
}
const route = Route.make({
id: "google-vertex-responses",
@@ -39,7 +39,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new Error("Google Vertex Responses does not support API keys")
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
const {
accessToken: _accessToken,
auth: _auth,
@@ -77,16 +77,17 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
if (apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
accessToken,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
}).model(modelID)
}
@@ -1,8 +1,10 @@
import type { AnyAuthClient } from "google-auth-library"
import { Effect, Redacted } from "effect"
import { Auth, MissingCredentialError } from "../route/auth.js"
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
const id = ProviderID.make("google-vertex")
export type OAuthOptions =
| { readonly accessToken?: string; readonly auth?: never }
@@ -35,12 +37,18 @@ export const host = (location: string) => {
export const requireProject = (value: string | undefined) => {
if (value) return value
throw new Error("Google Vertex requires a project when baseURL is not configured")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex requires a project when baseURL is not configured",
})
}
export const apiKey = (input: ApiKeyOptions) => {
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
}
@@ -68,7 +76,10 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new Error("Google Vertex accessToken cannot be combined with auth")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex accessToken cannot be combined with auth",
})
if (input.auth) return input.auth
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
+22 -13
View File
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export interface GeminiOptionsInput extends Gemini.OptionsInput {
@@ -26,6 +26,7 @@ export type Config = RouteDefaultsInput &
}
export type Settings = ProviderPackage.Settings &
GeminiProviderOptionsInput &
(
| { readonly accessToken?: string; readonly apiKey?: never }
| { readonly accessToken?: never; readonly apiKey?: string }
@@ -33,7 +34,6 @@ export type Settings = ProviderPackage.Settings &
readonly baseURL?: string
readonly location?: string
readonly project?: string
readonly providerOptions?: GeminiProviderOptionsInput
}
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
@@ -93,7 +93,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
const apiKey = GoogleVertexShared.apiKey(input)
const endpointModel = String(modelID).startsWith("endpoints/")
if (apiKey !== undefined && endpointModel)
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex tuned models do not support Express Mode API keys",
})
const location = GoogleVertexShared.location(inputLocation, "us-central1")
const project = GoogleVertexShared.project(inputProject)
const endpoint =
@@ -121,16 +124,22 @@ export const provider = {
id,
configure,
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (
modelID,
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
) => {
if (apiKey !== undefined && accessToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
location: settings.location,
project: settings.project,
providerOptions: settings.providerOptions,
...(apiKey === undefined ? { accessToken: accessToken } : { apiKey: apiKey }),
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
location,
project,
providerOptions,
}).model(modelID)
}
+14 -11
View File
@@ -20,11 +20,11 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: Gemini.ProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Gemini.ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
Gemini.ProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
const auth = (options: ProviderAuthOption<"optional">) => {
if ("auth" in options && options.auth) return options.auth
@@ -57,13 +57,16 @@ export const configure = (input: Config = {}) => {
}
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export const image = provider.image
+14 -11
View File
@@ -26,11 +26,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptions
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export type Settings = ProviderPackage.Settings &
ProviderOptions & {
readonly apiKey?: string
readonly baseURL?: string
}
const Options = Schema.Struct({
includeReasoning: Schema.optional(Schema.Boolean),
@@ -103,13 +103,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Groq from "./groq.js"
+11 -11
View File
@@ -79,11 +79,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
ProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
const responsesRoute = Route.make({
id: "meta-responses",
@@ -169,13 +169,13 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenResponsesProvid
export const messagesModel: ProviderPackage.Definition<Settings, MessagesOptionsInput>["model"] = (modelID, settings) =>
fromSettings(settings).messages(modelID)
function fromSettings(settings: Settings) {
function fromSettings({ apiKey, baseURL, body, headers, ...providerOptions }: Settings) {
return configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
})
}
+11 -11
View File
@@ -40,11 +40,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptionsInput
}
export interface Settings<Options = MessagesOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
export type Settings<Options = MessagesOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
const ChatOptions = Schema.Struct({
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
@@ -127,14 +127,14 @@ export const provider = configure()
export const model: ProviderPackage.Definition<Settings<MessagesOptionsInput>, MessagesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export const messages = provider.messages
+9 -6
View File
@@ -3,11 +3,14 @@ import { MiniMax } from "../minimax.js"
export type Settings = MiniMax.Settings<MiniMax.ChatOptionsInput>
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
MiniMax.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).chat(modelID)
@@ -5,12 +5,12 @@ export type Settings = MiniMax.Settings<MiniMax.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, MiniMax.ResponsesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
MiniMax.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).responses(modelID)
+14 -11
View File
@@ -14,11 +14,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ProviderOptions
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ProviderOptions
}
export type Settings = ProviderPackage.Settings &
ProviderOptions & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = MistralChat.route
export const routes = [route]
@@ -39,13 +39,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Mistral from "./mistral.js"
+14 -11
View File
@@ -42,11 +42,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
const ChatOptions = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
@@ -133,13 +133,16 @@ export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as Moonshot from "./moonshot.js"
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.MessagesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.MessagesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).messages(modelID)
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.ResponsesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).responses(modelID)
@@ -16,12 +16,12 @@ export type Config = RouteDefaultsInput &
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenResponsesProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenResponsesProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
}
export const routes = [OpenAICompatibleResponses.route]
@@ -48,13 +48,13 @@ export const provider = {
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
provider,
providerOptions,
}).model(modelID)
+16 -13
View File
@@ -14,12 +14,12 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL: string
readonly provider?: string
}
export const routes = [OpenAICompatibleChat.route]
@@ -45,14 +45,17 @@ export const provider = {
configure,
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
provider: settings.provider,
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
provider,
providerOptions,
}).model(modelID)
export * as OpenAICompatible from "./openai-compatible.js"
+26 -17
View File
@@ -57,14 +57,14 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
},
})
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
}
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
@@ -116,19 +116,28 @@ export const configure = (input: Config = {}) => {
export const provider = configure()
const config = (settings: Settings): Config => {
const config = ({
apiKey,
baseURL,
body,
headers: given,
organization,
project,
queryParams,
...providerOptions
}: Settings): Config => {
const headers = {
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
...settings.headers,
...(organization === undefined ? {} : { "OpenAI-Organization": organization }),
...(project === undefined ? {} : { "OpenAI-Project": project }),
...given,
}
return {
apiKey: settings.apiKey,
baseURL: settings.baseURL,
apiKey,
baseURL,
headers: Object.keys(headers).length === 0 ? undefined : headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
queryParams: queryParams === undefined ? undefined : { ...queryParams },
}
}
+11 -11
View File
@@ -77,11 +77,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenRouterProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
@@ -191,12 +191,12 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
+14 -11
View File
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: OpenAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: OpenAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
OpenAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export const route = Route.make({
id: "togetherai-chat",
@@ -52,11 +52,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
export const provider = configure()
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers === undefined ? undefined : { ...settings.headers },
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers: headers === undefined ? undefined : { ...headers },
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
+11 -11
View File
@@ -20,11 +20,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: XAIProviderOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: XAIProviderOptionsInput
}
export type Settings = ProviderPackage.Settings &
XAIProviderOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export type { XAIImageOptions } from "../protocols/xai-images.js"
@@ -110,13 +110,13 @@ export const model: ProviderPackage.Definition<
Settings,
XAIProviderOptionsInput,
typeof responsesRoute.compact
>["model"] = (modelID, settings) =>
>["model"] = (modelID, { apiKey, baseURL, body, headers, ...providerOptions }) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export const responses = provider.responses
export const chat = provider.chat
+14 -11
View File
@@ -23,11 +23,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
Options & {
readonly apiKey?: string
readonly baseURL?: string
}
const chatRoute = Route.make({
id: "zai-coding-chat",
@@ -80,13 +80,16 @@ export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as ZAICodingPlan from "./zai-coding-plan.js"
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.MessagesOptionsInput
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.MessagesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).messages(modelID)
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.ResponsesOptionsInpu
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.ResponsesOptionsInput>["model"] = (
modelID,
settings,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
ZAICodingPlan.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).responses(modelID)
+14 -11
View File
@@ -17,11 +17,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
readonly providerOptions?: ChatOptionsInput
}
export interface Settings extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput
}
export type Settings = ProviderPackage.Settings &
ChatOptionsInput & {
readonly apiKey?: string
readonly baseURL?: string
}
export type { ZAIImageOptions } from "../protocols/zai-images.js"
@@ -70,13 +70,16 @@ export const provider = configure()
export const image = provider.image
export const chat = provider.chat
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
modelID,
{ apiKey, baseURL, body, headers, ...providerOptions },
) =>
configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
apiKey,
baseURL,
headers,
http: body === undefined ? undefined : { body: { ...body } },
providerOptions,
}).model(modelID)
export * as ZAI from "./zai.js"
+5 -1
View File
@@ -23,6 +23,7 @@ import {
LanguageModel,
LLMEvent,
InvalidProviderOutputError,
ProviderConfigurationError,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -128,7 +129,10 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
})
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
+13
View File
@@ -50,6 +50,19 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
route: Schema.optional(RouteID),
}) {}
/**
* Provider settings that are missing, conflicting, or unsupported, such as
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
* provider facade or package entrypoint configures a model, before any
* request exists, so it is not an `AIError` reason.
*/
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
"AI.Error.ProviderConfiguration",
)("ProviderConfiguration", {
provider: ProviderID,
message: Schema.String,
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
+26 -20
View File
@@ -3,6 +3,9 @@ import { model } from "@opencode/ai/providers/openai"
import { LLM } from "../src/index.js"
import { Endpoint } from "../src/route/endpoint.js"
const configuration = (provider: string, message: string) =>
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
@@ -185,13 +188,13 @@ describe("provider package entrypoints", () => {
baseURL: "https://provider.example.test/v1/",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" as const },
reasoningEffort: "high" as const,
}
const deepinfra = DeepInfra.model("google/gemma-3-27b-it", settings)
expect(deepinfra.route.id).toBe("deepinfra-chat")
expect(deepinfra.route.endpoint.baseURL).toBe("https://provider.example.test/v1/openai")
expect(deepinfra.route.defaults.providerOptions).toEqual(settings.providerOptions)
expect(deepinfra.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
expect(deepinfra.route.defaults.headers).toEqual(settings.headers)
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
})
@@ -207,7 +210,7 @@ describe("provider package entrypoints", () => {
apiKey: "fixture",
headers: { "x-application": "opencode" },
body: { custom: true },
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
})
expect(selected.provider).toBe(provider.id)
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
@@ -228,11 +231,11 @@ describe("provider package entrypoints", () => {
}
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
...settings,
providerOptions: { usage: true },
usage: true,
})
const xai = XAI.model("grok-4", {
...settings,
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
})
for (const selected of [openrouter, xai]) {
@@ -266,7 +269,8 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "low", store: true },
reasoningEffort: "low",
store: true,
})
expect(String(selected.provider)).toBe("example")
@@ -292,7 +296,7 @@ describe("provider package entrypoints", () => {
provider: "example",
headers: { "x-application": "opencode" },
body: { metadata: { user_id: "user_1" } },
providerOptions: { effort: "low" },
effort: "low",
})
expect(String(selected.provider)).toBe("example")
@@ -312,7 +316,7 @@ describe("provider package entrypoints", () => {
const Anthropic = await import("@opencode/ai/providers/anthropic")
const selected = Anthropic.model("claude-sonnet-4-6", {
apiKey: "fixture",
providerOptions: { thinking: { type: "adaptive" } },
thinking: { type: "adaptive" },
})
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
@@ -322,7 +326,7 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
).toThrow("Anthropic-compatible providers require a baseURL")
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
})
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
@@ -337,10 +341,10 @@ describe("provider package entrypoints", () => {
baseURL: "https://messages.example.test/v1",
},
]),
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
).toThrow("Anthropic apiKey cannot be combined with authToken")
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
})
test("maps legacy OpenAI organization and project settings to headers", () => {
@@ -406,7 +410,7 @@ describe("provider package entrypoints", () => {
baseURL: "https://generativelanguage.test/v1beta",
headers: { "x-application": "opencode" },
body: { safetySettings: [] },
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
thinkingConfig: { thinkingBudget: 1_024 },
})
expect(selected.route.id).toBe("gemini")
@@ -490,43 +494,45 @@ describe("provider package entrypoints", () => {
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
{ accessToken: "token", auth: {}, project: "vertex-project" },
])
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() => configured.model("gemini-3.5-flash")).toThrow(
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
)
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Messages does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
expect(() =>
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Messages does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Chat does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
expect(() =>
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Chat does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Responses does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
expect(() =>
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow("Google Vertex Responses does not support API keys")
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
})
})
+7 -1
View File
@@ -76,7 +76,13 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
expect(() => Alibaba.configure({ region })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "alibaba",
message: `Alibaba region ${region} requires workspaceID or baseURL`,
}),
)
for (const config of [
{ baseURL: "https://gateway.example/prefix" },
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
@@ -69,7 +69,9 @@ for (const model of [
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
expect(request.headers["anthropic-beta"]).toBe(
"existing-beta,interleaved-thinking-2025-05-14,compact-2026-01-12",
)
if (body.messages.length === 1) {
expect(body.context_management.edits).toEqual([
{
@@ -32,7 +32,9 @@ for (const [id, enabled] of [
enabled ? { type: "adaptive", block_binding: { prefix_mismatch_behavior: "drop_block" } } : undefined,
)
expect(prepared.request.headers["anthropic-beta"]).toBe(
enabled ? "existing-beta,thinking-binding-controls-2026-08-01" : "existing-beta",
enabled
? "existing-beta,interleaved-thinking-2025-05-14,thinking-binding-controls-2026-08-01"
: "existing-beta,interleaved-thinking-2025-05-14",
)
}),
)
@@ -53,7 +55,9 @@ it.effect("preserves explicit thinking settings and combines required beta heade
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
expect(compiled.body.thinking).toEqual(thinking)
expect(prepared.request.headers["anthropic-beta"]).toBe(
thinking.type === "disabled" ? "compact-2026-01-12" : "compact-2026-01-12,thinking-binding-controls-2026-08-01",
thinking.type === "disabled"
? "interleaved-thinking-2025-05-14,compact-2026-01-12"
: "interleaved-thinking-2025-05-14,compact-2026-01-12,thinking-binding-controls-2026-08-01",
)
}
}),
@@ -1458,7 +1458,13 @@ describe("Bedrock Converse route", () => {
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
}
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "amazon-bedrock",
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
}),
)
}).pipe(
withProcessEnv({
...noAmbientAWS,
@@ -378,7 +378,11 @@ describe("Google Vertex providers", () => {
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
"Google Vertex tuned models do not support Express Mode API keys",
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "google-vertex",
message: "Google Vertex tuned models do not support Express Mode API keys",
}),
)
})
})
+4 -6
View File
@@ -34,12 +34,10 @@ it.effect("Groq lowers its own options for custom catalog identities and endpoin
baseURL: "https://gateway.example/v1",
headers: { "x-client": "test" },
body: { custom: "value" },
providerOptions: {
reasoningEffort: "default",
parallelToolCalls: true,
serviceTier: "flex",
user: "test-user",
},
reasoningEffort: "default",
parallelToolCalls: true,
serviceTier: "flex",
user: "test-user",
}),
{ provider: "custom-groq" },
)
+1 -1
View File
@@ -87,7 +87,7 @@ it.effect("Meta package selectors preserve overrides and Chat token policy on cu
baseURL: "https://gateway.example/v1",
headers: { "x-client": "test" },
body: { custom: "value" },
providerOptions: { reasoningEffort: "future-effort" },
reasoningEffort: "future-effort",
})
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
@@ -99,7 +99,7 @@ describe("native OpenAI-compatible providers", () => {
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
}
const selected = provider.configure(settings).model("test-model")
expect(selected.provider).toBe(provider.id)
@@ -143,7 +143,7 @@ describe("native OpenAI-compatible providers", () => {
tools: [
ToolDefinition.make({ name: "lookup", description: "Look up data", inputSchema: { type: "object" } }),
],
providerOptions: { store: true },
store: true,
}),
)
@@ -168,7 +168,7 @@ describe("native OpenAI-compatible providers", () => {
]),
Message.user("Continue."),
],
providerOptions: { store: true },
store: true,
}),
)
@@ -206,7 +206,7 @@ describe("native OpenAI-compatible providers", () => {
baseURL: "https://gateway.example/v1",
headers: { "x-application": "opencode" },
body: { service_tier: "priority" },
providerOptions: { reasoningEffort: "high" },
reasoningEffort: "high",
})
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
@@ -0,0 +1,78 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
const frame = {
type: "error",
sequence_number: 4,
code: "server_shutting_down",
message: "Server is shutting down. Please retry your request.",
param: null,
}
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
const event = yield* decode(JSON.stringify(frame))
expect(event).toEqual({
type: "error",
sequence_number: 4,
error: { code: frame.code, message: frame.message, param: null },
})
for (const unchanged of [
event,
{ type: "error" },
{
type: "response.failed",
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
},
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
]) {
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
}
}
}),
)
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
}),
)
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
"type": "error",
"sequence_number": 4,
"code": "server_shutting_down",
"message": "Server is shutting down. Please retry your request.",
"param": null,
"diagnostic": "retain-original-frame"
}`
for (const model of [
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
"example-model",
),
]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
Effect.flip,
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
expect(error.reason.body).toBe(raw)
expect(error.reason.http?.status).toBe(200)
}
}),
)
@@ -19,11 +19,13 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("alert")).toBeVisible()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(dialog.getByRole("alert")).toBeVisible()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
@@ -43,6 +45,17 @@ story("adding a server keeps all SSH challenges in the original connection dialo
await expect(dialog).toHaveCount(0)
})
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
await mount("app-dialog-ssh--incompatible-host")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
})
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
await timeline.send(
partUpdated(
toolPart(
@@ -134,7 +134,6 @@ test("combines follow-up patches into one three-file stack inside Used", async (
"true",
)
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
+6 -2
View File
@@ -54,8 +54,12 @@ export function createWebPlatform(version: string) {
function getCurrentServerUrl() {
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
if (import.meta.env.DEV) {
const loopback =
location.hostname === "localhost" || location.hostname === "[::1]" || location.hostname.startsWith("127.")
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? (loopback ? location.hostname : "localhost")
return `http://${host}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
}
return location.origin
}
+5 -2
View File
@@ -10,6 +10,7 @@ import { createData } from "@opencode/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
@@ -33,6 +34,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
const models = createGlobalModels()
const notificationCoordinator = createNotificationCoordinator()
const settingsServer = createMemo(() => {
const list = server.list
@@ -57,7 +59,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
if (existing) return existing
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)
return createServerController(conn, server.scope(key), server.projects.forServer(key))
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
}, owner)
serverCtxs.set(key, serverCtx)
return serverCtx
@@ -131,6 +133,7 @@ function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
) {
const language = useLanguage()
const settings = useSettings()
@@ -159,7 +162,7 @@ function createServerController(
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -278,6 +278,7 @@ function Open(props: { initial?: string }) {
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
+14 -2
View File
@@ -120,7 +120,13 @@ export function DialogSsh(props: {
<Divider />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6">
<Show when={!props.promptOnly && (!state.prompted || (!!error() && !prompt()))}>
<Show
when={
!props.promptOnly &&
item()?.stage !== "incompatible" &&
(!state.prompted || (!!error() && !prompt()))
}
>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label" for="ssh-target">
{language.t("ssh.target")}
@@ -160,6 +166,12 @@ export function DialogSsh(props: {
/>
</div>
</Show>
<Show when={item()?.stage === "incompatible"}>
<div class="flex w-full min-w-0 flex-col gap-2" role="status" aria-live="polite">
<span class="text-14-medium text-v2-text-text-base">{language.t("ssh.stage.incompatible")}</span>
<span class="text-13-regular text-v2-text-text-muted">{language.t("ssh.error.version")}</span>
</div>
</Show>
<Show when={prompt()} keyed>
{(prompt) => (
<div class="flex w-full min-w-0 flex-col gap-2">
@@ -195,7 +207,7 @@ export function DialogSsh(props: {
</div>
)}
</Show>
<Show when={error()}>
<Show when={item()?.stage !== "incompatible" && error()}>
{(error) => (
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
{error()}
@@ -1,6 +1,6 @@
import { createMemo, createUniqueId, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createQuery } from "@tanstack/solid-query"
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
import { Icon } from "@opencode/ui/icon"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
@@ -56,6 +56,7 @@ export function SessionFileBrowserTab(props: {
queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
enabled: serverSDK.connection.status() === "connected" && value.length > 0,
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
placeholderData: keepPreviousData,
}
})
const files = createMemo(() => {
@@ -0,0 +1,93 @@
import { onCleanup } from "solid-js"
const FOCUS_LOCK = "opencode:notification-focus"
const MAX_CLAIMED = 500
export function createNotificationCoordinator() {
const locks = typeof navigator === "undefined" ? undefined : navigator.locks
const claimed = new Set<string>()
const focus = { pending: false, release: undefined as (() => void) | undefined }
const updateFocus = () => {
if (typeof document === "undefined" || !document.hasFocus()) {
focus.release?.()
return
}
if (!locks || focus.pending || focus.release) return
focus.pending = true
void locks
.request(FOCUS_LOCK, { mode: "shared" }, async () => {
focus.pending = false
if (!document.hasFocus()) return
await new Promise<void>((resolve) => {
focus.release = resolve
})
focus.release = undefined
})
.catch(() => {
focus.pending = false
})
}
if (typeof window !== "undefined") {
window.addEventListener("focus", updateFocus)
window.addEventListener("blur", updateFocus)
document.addEventListener("visibilitychange", updateFocus)
updateFocus()
onCleanup(() => {
window.removeEventListener("focus", updateFocus)
window.removeEventListener("blur", updateFocus)
document.removeEventListener("visibilitychange", updateFocus)
focus.release?.()
})
}
const once = async (kind: "sound" | "system", eventID: string, run: () => Promise<unknown> | void) => {
const key = `${kind}:${eventID}`
const execute = async () => {
if (!claim(kind, key, claimed)) return
await run()
}
if (!locks) return execute()
await locks.request(`opencode:notification:${key}`, execute)
}
return {
sound(eventID: string, run: () => Promise<unknown> | void) {
return once("sound", eventID, run)
},
system(eventID: string, run: () => Promise<unknown> | void) {
return once("system", eventID, async () => {
if (typeof document !== "undefined" && document.hasFocus()) return
if (!locks) return run()
await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => {
if (!lock) return
await run()
})
})
},
}
}
function claim(kind: "sound" | "system", eventID: string, claimed: Set<string>) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const storageKey = `opencode:notification-${kind}`
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
@@ -11,7 +11,8 @@ import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundByIdOnce } from "@/shell/notifications/sound"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
@@ -114,7 +115,12 @@ function buildNotificationIndex(list: Notification[]) {
return index
}
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
export function createServerNotificationState(input: {
sdk: ServerSDK
data: Data
key: ServerConnection.Key
coordinator: ReturnType<typeof createNotificationCoordinator>
}) {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
@@ -223,7 +229,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
if (session.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
}
append({
@@ -235,8 +241,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
})
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
),
)
}
})
@@ -248,7 +256,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
if (session?.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
}
append({
@@ -263,8 +271,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
),
)
}
})
@@ -74,9 +74,6 @@ function getLoads() {
}
const cache = new Map<SoundID, Promise<string | undefined>>()
const claimed = new Set<string>()
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
const MAX_CLAIMED = 500
export function soundSrc(id: string | undefined) {
const loads = getLoads()
@@ -103,34 +100,3 @@ export function playSound(src: string | undefined) {
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
const play = async () => {
if (!claim(eventID)) return
await playSoundById(id)
}
if (typeof navigator === "undefined" || !navigator.locks) return play()
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
}
function claim(eventID: string) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
+5 -1
View File
@@ -76,7 +76,10 @@ export function buildEffortSelectOption(input: {
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
value: variant,
name: formatVariantName(variant),
})),
}
}
@@ -125,6 +128,7 @@ export function formatVariantName(variant: string) {
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
+4 -2
View File
@@ -201,7 +201,7 @@ export async function streamTurn(input: {
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
sessionUpdate: "agent_thought_chunk",
messageId: event.data.assistantMessageID,
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
content: { type: "text", text: event.data.delta },
})
continue
@@ -455,6 +455,8 @@ async function replayMessage(
return
}
if (message.type !== "assistant") return
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
let reasoningOrdinal = 0
for (const part of message.content) {
if (part.type === "text") {
await connection.sessionUpdate({
@@ -472,7 +474,7 @@ async function replayMessage(
sessionId: sessionID,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: message.id,
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
content: { type: "text", text: part.text },
},
})
+20 -5
View File
@@ -41,7 +41,12 @@ import type {
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode/schema/session-message"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import {
buildConfigOptions,
DEFAULT_VARIANT_VALUE,
parseModelSelection,
type ConfigOptionProvider,
} from "./config-option"
import { promptContentToParts } from "./content"
import {
ChildSessionUpdateMethod,
@@ -275,7 +280,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value)
const selected = requireModel(state.catalog, params.value, state.model)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
@@ -284,7 +289,10 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (!model?.variants.some((variant) => variant.id === params.value))
if (
!model ||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
)
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
@@ -453,7 +461,7 @@ function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
}))
}
function requireModel(catalog: Catalog, modelID: string): ModelRef {
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
@@ -461,7 +469,14 @@ function requireModel(catalog: Catalog, modelID: string): ModelRef {
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
return { providerID: model.providerID, id: model.id, variant: selected.variant }
const variant =
selected.variant ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant }
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
@@ -50,11 +50,11 @@ describe("acp config option subprocess", () => {
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
expect(effort.currentValue).toBe("default")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"])
}, 60_000)
test("effort switch updates currentValue", async () => {
test("effort survives model synchronization and can be reset to default", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
@@ -70,5 +70,23 @@ describe("acp config option subprocess", () => {
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
const synchronized = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: requireSelectOption(session.configOptions, "model").currentValue,
}),
)
expect(selectConfigOption(synchronized.configOptions, "effort")?.currentValue).toBe(nextEffort)
const reset = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: "default",
}),
)
expect(selectConfigOption(reset.configOptions, "effort")?.currentValue).toBe("default")
}, 60_000)
})
+44 -6
View File
@@ -91,7 +91,7 @@ describe("acp event behavior", () => {
}
})
test("preserves text and reasoning order before returning the terminal response", async () => {
test("preserves reasoning boundaries and update order during streaming and replay", async () => {
const firstUpdate = Promise.withResolvers<void>()
const releaseUpdate = Promise.withResolvers<void>()
const allUpdates = Promise.withResolvers<void>()
@@ -108,6 +108,14 @@ describe("acp event behavior", () => {
delta: "think-1",
}),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: " continued",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_order",
@@ -120,7 +128,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 2,
ordinal: 1,
delta: "think-2",
}),
)
@@ -144,7 +152,7 @@ describe("acp event behavior", () => {
firstUpdate.resolve()
await releaseUpdate.promise
}
if (updates.length === 3) allUpdates.resolve()
if (updates.length === 4) allUpdates.resolve()
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
@@ -171,18 +179,48 @@ describe("acp event behavior", () => {
) {
return [
item.update.sessionUpdate,
item.update.messageId,
item.update.content.type === "text" ? item.update.content.text : undefined,
]
}
return [item.update.sessionUpdate, undefined]
}),
).toEqual([
["agent_thought_chunk", "think-1"],
["agent_message_chunk", "answer"],
["agent_thought_chunk", "think-2"],
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
])
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
const replayed: SessionUpdateParams[] = []
await replayMessages(recordingConnection(replayed), "ses_order", "/workspace", [
{
id: "msg_order",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 1 },
content: [
{ type: "reasoning", text: "think-1 continued" },
{ type: "text", text: "answer" },
{ type: "reasoning", text: "think-2" },
],
},
])
expect(replayed).toEqual([
{
sessionId: "ses_order",
update: {
sessionUpdate: "agent_thought_chunk",
messageId: "msg_order:reasoning:0",
content: { type: "text", text: "think-1 continued" },
},
},
updates[2],
updates[3],
])
} finally {
releaseUpdate.resolve()
releaseSubmit.resolve()
@@ -222,7 +222,7 @@ describe("acp service directory behavior", () => {
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
expect(currentValue(selectedModel, "effort")).toBe("low")
expect(currentValue(selectedModel, "effort")).toBe("default")
expect(currentValue(selectedEffort, "effort")).toBe("medium")
expect(currentValue(selectedMode, "mode")).toBe("plan")
expect(
@@ -32,7 +32,7 @@ describe("acp service lifecycle", () => {
model: { providerID: "test", id: "second-model" },
},
})
expect(currentValue(created, "effort")).toBe("none")
expect(currentValue(created, "effort")).toBe("default")
})
test("loads and forks with paginated replay while resume does not replay", async () => {
+37 -5
View File
@@ -9,7 +9,8 @@ standard-library surface that programs can use today, plus concrete gaps that ma
- Intentional boundaries are not listed as compatibility work.
When behavior changes, update this file and the tests in the same change. The implementation and tests remain the
ultimate source of truth.
ultimate source of truth. Upstream test262 files run verbatim from `test/test262`; a failing file is listed in
`test/test262/skipped.txt` and its gap is an unchecked item here (see `test/test262/README.md`).
## Source and execution model
@@ -25,6 +26,10 @@ ultimate source of truth.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
shadowable by program declarations like other globals.
- [x] Cooperative timeout, an optional total tool-call limit, output bounding, and unrestricted tool-call concurrency.
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
rest parameter are accepted unless the program itself begins with `"use strict"`.
- [ ] Valid JavaScript rejected by TypeScript transpilation before interpretation, such as `in` inside a destructuring
default in a `for...of` head and Unicode-escaped keywords.
## Values and literals
@@ -64,6 +69,11 @@ ultimate source of truth.
- [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.
- [ ] Destructuring a key that member access resolves through the owning built-in, such as
`const { constructor } = error`, reads `undefined`.
- [ ] Member expressions as `for...in` targets (`for (x.y in obj)`).
## Statements and control flow
@@ -111,6 +121,15 @@ ultimate source of truth.
- [ ] 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,
assignments, object literal keys, and destructuring or parameter defaults.
- [ ] `name` and `length` of built-in functions such as `Math.max` or `"a".includes`.
- [ ] A named function expression's name is not bound inside its own body.
- [ ] Redeclaring a function in the same scope is rejected; in JavaScript the last declaration wins.
- [ ] A line terminator between `async function` and the function name.
- [ ] Async generator functions evaluate parameter defaults and destructuring at the first `next()` rather than at the
call, so their errors are not thrown synchronously.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
`next(value)`, `return(value)`, `throw(value)`, exhaustion, promise adoption, async request ordering,
`try`/`catch`/`finally`, and sync/async iterator symbols. Async `yield*` awaits values while adapting a sync
@@ -154,6 +173,8 @@ ultimate source of truth.
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
creates a hole without changing its length.
- [ ] Operators, `switch` discriminants, and coercion helpers such as `String` and `isNaN` applied to functions and
namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
## Promises and tools
@@ -225,7 +246,8 @@ ultimate source of truth.
- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS.
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
and null-prototype results.
and plain-object results.
- [ ] `Object.prototype` methods on values: `toString`, `toLocaleString`, `valueOf`, and `hasOwnProperty`.
## Arrays
@@ -245,10 +267,18 @@ ultimate source of truth.
- [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"` remain non-index properties rather than aliasing index
`1`; arbitrary array-property assignment remains unsupported.
- [x] Canonical array/string index parsing: keys such as `"01"` are ordinary properties rather than aliases of index
`1`.
- [x] `Array.prototype.sort` preserves trailing holes, while `toSorted` densifies holes into `undefined` elements,
like JavaScript.
- [x] Assigning `length` to truncate or extend an array; invalid lengths throw `RangeError`.
- [x] Non-index own properties on arrays (`arr.foo = 1`, `arr.constructor = null`). They are excluded from the JSON
form, like `JSON.stringify`.
- [ ] Argument coercion for `indexOf`, `lastIndexOf`, `includes`, `fill`, `flat`, `copyWithin`, and the `join`
separator: JavaScript applies ToIntegerOrInfinity/ToString (including `valueOf`, strings, and `undefined`), the
interpreter requires numbers and strings; `includes()`/`indexOf()` with no argument should search for
`undefined`.
- [ ] Iterator objects from `keys`, `values`, and `entries` with a live `next()`.
## Strings
@@ -332,7 +362,7 @@ ultimate source of truth.
- [x] `test`, `exec`, and `toString`.
- [x] Readable `source`, `flags`, `lastIndex`, `hasIndices`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`,
`unicodeSets`, and `dotAll`.
- [x] Captures, named groups, match `.index`, and stateful global matching.
- [x] Captures, named groups, match `.index` and `.input`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Writable `lastIndex`.
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
@@ -391,3 +421,5 @@ ultimate source of truth.
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
This is deliberate: the program should handle a failure the same way regardless of where it originated.
- [ ] Failures raised by the interpreter itself carry the generic `Error` name where JavaScript throws a `TypeError`,
`RangeError`, or `ReferenceError`, so `e instanceof TypeError` and `e.constructor === TypeError` are false.
+62
View File
@@ -0,0 +1,62 @@
// Copies the manifest's test262 directories from a local checkout into test/test262, verbatim.
// Files needing unsupported flags, features, or harness includes, or whose code crosses one of the
// interpreter's intentional boundaries, are not copied, so the vendored tree is exactly what
// test/test262.test.ts runs.
//
// Usage: bun run script/sync-test262.ts /path/to/test262
import path from "node:path"
import { rm } from "node:fs/promises"
type Frontmatter = { flags?: Array<string>; features?: Array<string>; includes?: Array<string> }
const root = path.resolve(import.meta.dir, "../test/test262")
const manifest = (await Bun.file(path.join(root, "manifest.json")).json()) as {
revision: string
directories: Array<string>
harness: Array<string>
flags: Array<string>
features: Array<string>
boundaries: Record<string, string>
}
const boundaries = Object.entries(manifest.boundaries).map(([name, pattern]) => [name, new RegExp(pattern)] as const)
const checkout = process.argv[2]
if (checkout === undefined) {
console.error("usage: bun run script/sync-test262.ts /path/to/test262")
process.exit(1)
}
const head = (await Bun.$`git -C ${checkout} rev-parse HEAD`.text()).trim()
if (head !== manifest.revision) {
console.error(`checkout is at ${head}; manifest pins ${manifest.revision}`)
process.exit(1)
}
const excluded = new Map<string, number>()
let copied = 0
for (const dir of manifest.directories) {
await rm(path.join(root, dir), { recursive: true, force: true })
const from = path.join(checkout, "test", dir)
for await (const file of new Bun.Glob("**/*.js").scan({ cwd: from })) {
if (file.endsWith("_FIXTURE.js")) continue
const source = await Bun.file(path.join(from, file)).text()
const start = source.indexOf("/*---")
const end = source.indexOf("---*/", start)
const meta = start === -1 ? {} : (Bun.YAML.parse(source.slice(start + 5, end)) as Frontmatter)
const code = start === -1 ? source : source.slice(end + 5)
const reason =
meta.flags?.find((flag) => manifest.flags.includes(flag)) ??
meta.features?.find((feature) => manifest.features.includes(feature)) ??
meta.includes?.find((include) => !manifest.harness.includes(include)) ??
boundaries.find(([, pattern]) => pattern.test(code))?.[0]
if (reason !== undefined) {
excluded.set(reason, (excluded.get(reason) ?? 0) + 1)
continue
}
await Bun.write(path.join(root, dir, file), Bun.file(path.join(from, file)))
copied++
}
}
console.log(`copied ${copied} files`)
for (const [reason, count] of [...excluded].sort((a, b) => b[1] - a[1])) {
console.log(` excluded ${String(count).padStart(5)} ${reason}`)
}
@@ -0,0 +1,55 @@
// Runs every vendored test262 file, including skipped ones, and groups failures by cause. Pass
// --write to regenerate test/test262/skipped.txt from the current failures.
//
// Usage: bun run script/test262-report.ts [--write] [path-prefix]
import path from "node:path"
import { root, run, skipped } from "../test/test262/run.js"
const write = process.argv.includes("--write")
const prefix = process.argv.slice(2).find((arg) => !arg.startsWith("--")) ?? ""
const files = [...new Bun.Glob("**/*.js").scanSync({ cwd: root })].filter((file) => file.startsWith(prefix)).sort()
const failures: Array<{ file: string; reason: string }> = []
const recovered: Array<string> = []
for (const file of files) {
const outcome = await run(file)
if (outcome.status === "fail") failures.push({ file, reason: outcome.reason })
if (outcome.status === "pass" && skipped.has(file)) recovered.push(file)
}
// Collapse a reason to the part that identifies the cause rather than the test.
const bucket = (reason: string) => {
const syntax = reason.match(/Syntax '([A-Za-z]+)' is not supported/)
if (syntax) return `unsupported syntax ${syntax[1]}`
if (reason.startsWith("expected ")) return reason.replace(/ but got .*/, " but the program ran")
return reason
.replace(/^(\$DONE: |ExecutionFailure: |InvalidDataValue: |ParseError: |Uncaught: |Test262Error: |Error: )+/, "")
.replace(/ \(line \d+, col \d+\)/, "")
.replace(/^[\w$]+\.(\w+) is not a function/, ".$1 is not a function")
.replace(/^[\w$]+ cannot be constructed/, "… cannot be constructed")
.replace(/'[^']*'/g, "'…'")
.slice(0, 100)
}
const buckets = new Map<string, Array<string>>()
for (const failure of failures) {
const key = bucket(failure.reason)
buckets.set(key, [...(buckets.get(key) ?? []), failure.file])
}
console.log(`${files.length - failures.length} pass, ${failures.length} fail of ${files.length}\n`)
for (const [key, list] of [...buckets].sort((a, b) => b[1].length - a[1].length)) {
console.log(`${String(list.length).padStart(5)} ${key}`)
for (const file of list.slice(0, 3)) console.log(` ${file}`)
if (list.length > 3) console.log(`${list.length - 3} more`)
}
if (recovered.length > 0) {
console.log(`\n${recovered.length} skipped files pass now; remove them from skipped.txt:`)
for (const file of recovered) console.log(` ${file}`)
}
if (write) {
const lines = failures.map((failure) => `${failure.file} # ${bucket(failure.reason)}`)
await Bun.write(path.join(root, "skipped.txt"), `${lines.join("\n")}\n`)
console.log(`\nwrote ${lines.length} entries to skipped.txt`)
}
+53 -25
View File
@@ -1,11 +1,16 @@
export * as Data from "./data.js"
import type { DiagnosticKind } from "./codemode.js"
import {
ownEntries,
parseArrayIndex,
ProgramArray,
ProgramFunction,
ProgramObject,
set,
} from "./interpreter/objects.js"
import { Values } from "./values.js"
/** A null-prototype object owned by the program. */
export type SafeObject = Record<string, unknown>
const MAX_VALUE_DEPTH = 32
export class ToolRuntimeError extends Error {
@@ -23,15 +28,15 @@ export class ToolRuntimeError extends Error {
}
/**
* Brings a host-produced runtime value into the program: runtime values pass through, their host
* counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and objects become
* null-prototype copies. Arrays keep extra enumerable properties such as `index` and `groups`.
* Brings a host-produced value into the program: program and runtime values pass through, their
* host counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and host objects
* and arrays are copied.
*/
export const toProgram = (value: unknown, label: string): unknown => copy(value, label, "program", 0, new Set())
/**
* Brings host data into the program: Date and URL become strings, other host collections become
* empty objects, and objects become null-prototype copies. Used for tool results and parsed JSON.
* empty objects, and objects become program copies. Used for tool results and parsed JSON.
*/
export const fromData = (value: unknown, label: string): unknown => copy(value, label, "data", 0, new Set())
@@ -44,8 +49,7 @@ export const fromData = (value: unknown, label: string): unknown => copy(value,
export const toData = (value: unknown, label: string, undefinedAs: "json" | "result" = "json"): unknown =>
copy(value, label, undefinedAs, 0, new Set())
// "program" and "data" build program-owned null-prototype objects; "json" and "result" build
// ordinary objects for the host.
// "program" and "data" build program objects; "json" and "result" build ordinary objects for the host.
type Mode = "program" | "data" | "json" | "result"
const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Set<object>): unknown => {
@@ -64,9 +68,13 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
)
}
if (value instanceof ProgramFunction && mode !== "program") {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
const plain = mode === "program" || mode === "data"
if (mode === "program") {
if (Values.isValue(value)) return value
if (value instanceof ProgramObject || Values.isValue(value)) return value
if (value instanceof Date) return new Values.Date(value.getTime())
if (value instanceof RegExp) return new Values.RegExp(value.source, value.flags)
if (value instanceof Map) {
@@ -85,7 +93,6 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
if (value instanceof URLSearchParams) return new Values.URLSearchParams(new URLSearchParams(value))
}
const plain = mode === "program" || mode === "data"
if (value instanceof Values.Date) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null
if (value instanceof Values.URL) return value.url.href
@@ -98,7 +105,7 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
value instanceof Set ||
value instanceof URLSearchParams
) {
return plain ? (Object.create(null) as SafeObject) : {}
return plain ? new ProgramObject() : {}
}
if (seen.has(value)) {
@@ -106,27 +113,48 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
}
seen.add(value)
if (Array.isArray(value)) {
// Host output densifies holes to null like JSON; program copies keep them.
const copied = plain
? value.map((item) => copy(item, label, mode, depth + 1, seen))
: Array.from(value, (item) => copy(item, label, mode, depth + 1, seen) ?? null)
if (mode === "program") {
for (const [key, item] of Object.entries(value)) {
if (Object.hasOwn(copied, key)) continue
define(copied, key, copy(item, label, mode, depth + 1, seen))
}
if (value instanceof ProgramArray) {
const copied = Array.from(value.items, (item) => copy(item, label, mode, depth + 1, seen) ?? null)
seen.delete(value)
return copied
}
if (value instanceof ProgramObject) {
const copied: Record<string, unknown> = {}
for (const [key, item] of ownEntries(value)) {
const next = copy(item, label, mode, depth + 1, seen)
if (next === undefined && mode === "json") continue
define(copied, key, next)
}
seen.delete(value)
return copied
}
if (Array.isArray(value)) {
if (plain) {
const copied = new ProgramArray(value.map((item) => copy(item, label, mode, depth + 1, seen)))
for (const [key, item] of Object.entries(value)) {
if (parseArrayIndex(key) === undefined) set(copied, key, copy(item, label, mode, depth + 1, seen))
}
seen.delete(value)
return copied
}
const copied = Array.from(value, (item) => copy(item, label, mode, depth + 1, seen) ?? null)
seen.delete(value)
return copied
}
const prototype = Object.getPrototypeOf(value)
if (prototype !== Object.prototype && prototype !== null) {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
}
const copied: SafeObject = plain ? (Object.create(null) as SafeObject) : {}
if (plain) {
const copied = new ProgramObject()
for (const [key, item] of Object.entries(value)) set(copied, key, copy(item, label, mode, depth + 1, seen))
seen.delete(value)
return copied
}
const copied: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
const next = copy(item, label, mode, depth + 1, seen)
if (next === undefined && mode === "json") continue
@@ -136,8 +164,8 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
return copied
}
// Own data property regardless of the target's prototype, so a "__proto__" key on a host object or
// array never reaches the Object.prototype setter.
// Own data property regardless of the target's prototype, so a "__proto__" key on a host object
// never reaches the Object.prototype setter.
const define = (target: object, key: string, value: unknown): void => {
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
}
+6 -9
View File
@@ -1,10 +1,11 @@
import { Effect } from "effect"
import type { Diagnostic } from "../codemode.js"
import { ToolError } from "../tool-error.js"
import { type SafeObject, toData, ToolRuntimeError } from "../data.js"
import { toData, ToolRuntimeError } from "../data.js"
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
import { containsRuntimeReference } from "./references.js"
import { type HostCall, HostFunction } from "./host.js"
import { get, ProgramError, ProgramObject } from "./objects.js"
import { type Runner } from "./runner.js"
import {
coerceToString,
@@ -44,12 +45,8 @@ export const normalizeError = (error: unknown): Diagnostic => {
message = "a non-data value"
} else if (typeof value === "string") {
message = value
} else if (
value !== null &&
typeof value === "object" &&
typeof (value as { message?: unknown }).message === "string"
) {
message = (value as { message: string }).message
} else if (value instanceof ProgramObject && typeof get(value, "message") === "string") {
message = get(value, "message") as string
} else {
try {
message = JSON.stringify(toData(value, "Thrown value")) ?? String(value)
@@ -87,14 +84,14 @@ export const caughtErrorValue = (thrown: unknown): unknown => {
return createErrorValue(name, normalizeError(thrown).message)
}
const constructErrorValue = (name: string, args: Array<unknown>): SafeObject =>
const constructErrorValue = (name: string, args: Array<unknown>): ProgramError =>
createErrorValue(name, args[0] === undefined ? "" : coerceToString(args[0]))
const constructAggregateErrorValue = <R>(
runner: Runner<R>,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<SafeObject, unknown, R> =>
): Effect.Effect<ProgramError, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
+10 -1
View File
@@ -7,6 +7,7 @@ import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../
import { toData } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import type { Host } from "./globals.js"
import { InterpreterRuntimeError } from "./model.js"
import { PromiseRuntime } from "./promises.js"
import { Runtime } from "./runtime.js"
@@ -16,6 +17,7 @@ export const executeProgram = <R>(
prepared: ToolRuntime.Prepared<R>,
limits: ResolvedExecutionLimits,
hooks: ToolRuntime.ToolCallHooks<R>,
extraGlobals?: (host: Host<R>) => ReadonlyArray<readonly [string, unknown]>,
): Effect.Effect<Result, never, R> => {
if (code.trim().length === 0) {
return Effect.succeed({
@@ -39,7 +41,14 @@ export const executeProgram = <R>(
Effect.gen(function* () {
const program = parseProgram(code)
const promises = new PromiseRuntime<R>(scope)
const value = yield* new Runtime<R>(tools.execute, tools.search, tools.keys, promises, logs).run(program)
const value = yield* new Runtime<R>(
tools.execute,
tools.search,
tools.keys,
promises,
logs,
extraGlobals,
).run(program)
const result = toData(value, "Execution result", "result") as DataValue
returned = { value: result, promises }
const warnings = yield* promises.interrupt()
+61 -58
View File
@@ -1,13 +1,14 @@
import { Effect } from "effect"
import { type SafeObject, toProgram } from "../data.js"
import { toProgram } from "../data.js"
import { dateSetterArgumentCount, invokeDateMethod } from "../stdlib/date.js"
import { invokeNumberMethod } from "../stdlib/number.js"
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
import { invokeURLMethod, uriArgument } from "../stdlib/url.js"
import { coerceToNumber, coerceToString, errorBrandName } from "../stdlib/value.js"
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
import { compareText } from "../tool-runtime.js"
import { Values } from "../values.js"
import { type AstNode, IntrinsicReference, InterpreterRuntimeError } from "./model.js"
import { get, ProgramArray, ProgramObject, record } from "./objects.js"
import { containsOpaqueReference, rejectCircularInsertion, typeofValue } from "./references.js"
import { applyCollectionCallback, isSupportedCallback, type Runner, toPrimitive } from "./runner.js"
@@ -16,6 +17,14 @@ export const invokeIntrinsic = <R>(
ref: IntrinsicReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> =>
Effect.map(invoke(runner, ref, args, node), (result) => (Array.isArray(result) ? new ProgramArray(result) : result))
const invoke = <R>(
runner: Runner<R>,
ref: IntrinsicReference,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (typeof ref.receiver === "string") {
if (ref.name === "replace" || ref.name === "replaceAll") {
@@ -32,7 +41,7 @@ export const invokeIntrinsic = <R>(
if (typeof ref.receiver === "number") {
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
}
if (Array.isArray(ref.receiver)) {
if (ref.receiver instanceof ProgramArray) {
return invokeArrayMethod(runner, ref.receiver, ref.name, args, node)
}
if (ref.receiver instanceof Values.Date) {
@@ -215,7 +224,7 @@ const invokeStringMethod = (value: string, name: string, args: Array<unknown>, n
node,
)
}
return Array.from(value.matchAll(pattern), matchToValue)
return new ProgramArray(Array.from(value.matchAll(pattern), matchToValue))
}
case "search": {
result = value.search(toHostRegex(args[0], name, node))
@@ -288,13 +297,7 @@ const invokeStringReplacer = <R>(
if (typeof match !== "string" || typeof offset !== "number") {
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
}
if (hasGroups) {
const safeGroups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(groups)) {
safeGroups[key] = group
}
callbackArgs[callbackArgs.length - 1] = safeGroups
}
if (hasGroups) callbackArgs[callbackArgs.length - 1] = record(groups as Record<string, unknown>)
matches.push({ match, offset, args: callbackArgs })
return match
}
@@ -320,14 +323,11 @@ const invokeStringReplacer = <R>(
let end = 0
for (const match of matches) {
const replacement = yield* apply(match.args)
// Error values are branded plain objects; toProgram would strip the brand before coercion.
output.push(
value.slice(end, match.offset),
replacement instanceof Values.Promise
? "[object Promise]"
: errorBrandName(replacement)
? coerceToString(replacement)
: coerceToString(toProgram(replacement, `String.${name} replacer result`)),
: coerceToString(toProgram(replacement, `String.${name} replacer result`)),
)
end = match.offset + match.match.length
}
@@ -365,7 +365,7 @@ const invokeMapMethod = <R>(
case "values":
return Effect.sync(() => Array.from(target.map.values()))
case "entries":
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]) => new ProgramArray([key, item])))
case "forEach": {
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
return Effect.gen(function* () {
@@ -404,7 +404,7 @@ const invokeSetMethod = <R>(
case "values":
return Effect.sync(() => Array.from(target.set.values()))
case "entries":
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
return Effect.sync(() => Array.from(target.set.values(), (item) => new ProgramArray([item, item])))
case "forEach": {
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
return Effect.gen(function* () {
@@ -518,28 +518,27 @@ const loadSetRecord = <R>(runner: Runner<R>, source: unknown, name: string, node
keys: () => Effect.succeed(source.map.keys()),
})
}
if (source === null || typeof source !== "object" || Values.isValue(source)) {
if (!(source instanceof ProgramObject)) {
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node).as("TypeError")
}
const object = source as Record<string, unknown>
return Effect.gen(function* () {
const size = yield* coerceNumericArgument(runner, object.size, node)
const size = yield* coerceNumericArgument(runner, get(source, "size"), node)
if (Number.isNaN(size)) {
throw new InterpreterRuntimeError(`Set.${name} received a Set-like object with an invalid size.`, node).as(
"TypeError",
)
}
if (!isSupportedCallback(object.has) || !isSupportedCallback(object.keys)) {
const has = get(source, "has")
const keys = get(source, "keys")
if (!isSupportedCallback(has) || !isSupportedCallback(keys)) {
throw new InterpreterRuntimeError(`Set.${name} expects callable 'has' and 'keys' methods.`, node).as("TypeError")
}
const has = object.has
const keys = object.keys
return {
size: Math.max(Math.trunc(size), 0),
has: (item: unknown) => Effect.map(runner.invokeCallable(has, [item], node), Boolean),
keys: () =>
Effect.flatMap(runner.invokeCallable(keys, [], node), (result) => {
if (Array.isArray(result)) return Effect.succeed(result)
if (result instanceof ProgramArray) return Effect.succeed(result.items)
throw new InterpreterRuntimeError(`Set.${name} expected 'keys' to return an iterator.`, node).as("TypeError")
}),
}
@@ -604,7 +603,7 @@ const invokeURLSearchParamsMethod = <R>(
case "values":
return Effect.sync(() => Array.from(target.params.values()))
case "entries":
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]) => new ProgramArray([key, value])))
case "toString":
return Effect.sync(() => target.params.toString())
case "forEach": {
@@ -622,11 +621,12 @@ const invokeURLSearchParamsMethod = <R>(
const invokeArrayMethod = <R>(
runner: Runner<R>,
target: Array<unknown>,
receiver: ProgramArray,
name: string,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const target = receiver.items
const optNumber = (value: unknown, label: string): number | undefined => {
if (value === undefined) return undefined
if (typeof value !== "number")
@@ -638,9 +638,8 @@ const invokeArrayMethod = <R>(
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
}
const input = toProgram(target, "Array.join input") as Array<unknown>
return Effect.succeed(
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
target.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
)
}
case "includes":
@@ -660,11 +659,15 @@ const invokeArrayMethod = <R>(
case "slice":
return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))
case "concat":
return Effect.succeed(target.concat(...args))
case "flat":
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
return Effect.succeed(target.concat(...args.map((item) => (item instanceof ProgramArray ? item.items : item))))
case "flat": {
const flatten = (items: Array<unknown>, depth: number): Array<unknown> =>
items.flatMap((item) => (item instanceof ProgramArray && depth > 0 ? flatten(item.items, depth - 1) : [item]))
return Effect.succeed(flatten(target, optNumber(args[0], "depth") ?? 1))
}
case "reverse":
return Effect.succeed(target.reverse())
target.reverse()
return Effect.succeed(receiver)
case "sort": {
const length = target.length
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(target, index)).filter((own) => !own).length
@@ -676,7 +679,7 @@ const invokeArrayMethod = <R>(
Array.from({ length: holeCount }, (_, index) => itemCount + index).forEach((index) => {
Reflect.deleteProperty(target, index)
})
return target
return receiver
})
}
case "toSorted":
@@ -695,12 +698,12 @@ const invokeArrayMethod = <R>(
}
case "push": {
// Validate all insertions before mutating to avoid partial cyclic updates.
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
for (const item of args) rejectCircularInsertion(receiver, item, "Array.push result", node)
target.push(...args)
return Effect.succeed(target.length)
}
case "unshift": {
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
for (const item of args) rejectCircularInsertion(receiver, item, "Array.unshift result", node)
target.unshift(...args)
return Effect.succeed(target.length)
}
@@ -714,7 +717,7 @@ const invokeArrayMethod = <R>(
if (args.length === 1) return Effect.succeed(target.splice(start))
const deleteCount = optNumber(args[1], "delete count") ?? 0
const inserted = args.slice(2)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
for (const item of inserted) rejectCircularInsertion(receiver, item, "Array.splice result", node)
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
}
case "toSpliced": {
@@ -731,23 +734,23 @@ const invokeArrayMethod = <R>(
return Effect.succeed(copied)
}
case "fill": {
rejectCircularInsertion(target, args[0], "Array.fill result", node)
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
rejectCircularInsertion(receiver, args[0], "Array.fill result", node)
target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end"))
return Effect.succeed(receiver)
}
case "copyWithin":
return Effect.succeed(
target.copyWithin(
optNumber(args[0], "target index") ?? 0,
optNumber(args[1], "start") ?? 0,
optNumber(args[2], "end"),
),
target.copyWithin(
optNumber(args[0], "target index") ?? 0,
optNumber(args[1], "start") ?? 0,
optNumber(args[2], "end"),
)
return Effect.succeed(receiver)
case "keys":
return Effect.succeed(Array.from(target.keys()))
case "values":
return Effect.succeed([...target])
case "entries":
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
return Effect.succeed(Array.from(target.entries(), ([index, item]) => new ProgramArray([index, item])))
}
const apply = applyCollectionCallback(runner, args[0], `Array.${name}`, node)
@@ -760,7 +763,7 @@ const invokeArrayMethod = <R>(
values.length = length
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
values[index] = yield* apply([target[index], index, target])
values[index] = yield* apply([target[index], index, receiver])
}
return values
}
@@ -768,8 +771,8 @@ const invokeArrayMethod = <R>(
const values: Array<unknown> = []
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
const mapped = yield* apply([target[index], index, target])
if (Array.isArray(mapped)) values.push(...mapped)
const mapped = yield* apply([target[index], index, receiver])
if (mapped instanceof ProgramArray) values.push(...mapped.items)
else values.push(mapped)
}
return values
@@ -779,36 +782,36 @@ const invokeArrayMethod = <R>(
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
const item = target[index]
if (yield* apply([item, index, target])) values.push(item)
if (yield* apply([item, index, receiver])) values.push(item)
}
return values
}
case "find":
for (let index = 0; index < length; index += 1) {
const item = target[index]
if (yield* apply([item, index, target])) return item
if (yield* apply([item, index, receiver])) return item
}
return undefined
case "findIndex":
for (let index = 0; index < length; index += 1) {
if (yield* apply([target[index], index, target])) return index
if (yield* apply([target[index], index, receiver])) return index
}
return -1
case "some":
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
if (yield* apply([target[index], index, target])) return true
if (yield* apply([target[index], index, receiver])) return true
}
return false
case "every":
for (let index = 0; index < length; index += 1) {
if (!(index in target)) continue
if (!(yield* apply([target[index], index, target]))) return false
if (!(yield* apply([target[index], index, receiver]))) return false
}
return true
case "forEach":
for (let index = 0; index < length; index += 1) {
if (index in target) yield* apply([target[index], index, target])
if (index in target) yield* apply([target[index], index, receiver])
}
return undefined
case "reduce": {
@@ -825,7 +828,7 @@ const invokeArrayMethod = <R>(
}
for (let index = start; index < length; index += 1) {
if (!(index in target)) continue
accumulator = yield* apply([accumulator, target[index], index, target])
accumulator = yield* apply([accumulator, target[index], index, receiver])
}
return accumulator
}
@@ -843,19 +846,19 @@ const invokeArrayMethod = <R>(
}
for (let index = start; index >= 0; index -= 1) {
if (!(index in target)) continue
accumulator = yield* apply([accumulator, target[index], index, target])
accumulator = yield* apply([accumulator, target[index], index, receiver])
}
return accumulator
}
case "findLast":
for (let index = length - 1; index >= 0; index -= 1) {
const item = target[index]
if (yield* apply([item, index, target])) return item
if (yield* apply([item, index, receiver])) return item
}
return undefined
case "findLastIndex":
for (let index = length - 1; index >= 0; index -= 1) {
if (yield* apply([target[index], index, target])) return index
if (yield* apply([target[index], index, receiver])) return index
}
return -1
}
+3 -13
View File
@@ -1,7 +1,7 @@
import type { BlockStatement, Expression, Node, Pattern } from "acorn"
import type { Node } from "acorn"
import type { Effect } from "effect"
import type { DiagnosticKind } from "../codemode.js"
import type { SafeObject } from "../data.js"
import type { ProgramObject } from "./objects.js"
import type { Values } from "../values.js"
/** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */
@@ -20,20 +20,10 @@ export type StatementResult =
| { kind: "continue"; label?: string }
export type MemberReference = {
target: SafeObject | Array<unknown> | Values.RegExp | Values.URL
target: ProgramObject | Values.RegExp | Values.URL
key: PropertyKey
}
export class CodeModeFunction {
constructor(
readonly parameters: ReadonlyArray<Pattern>,
readonly body: BlockStatement | Expression,
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
readonly async: boolean,
readonly generator: boolean,
) {}
}
export type GeneratorRequestKind = "next" | "return" | "throw"
export class CodeModeGenerator {
@@ -0,0 +1,145 @@
import type { BlockStatement, Expression, Pattern } from "acorn"
import { AsyncIteratorSymbol, type Binding, IteratorSymbol } from "./model.js"
/** An object owned by the program: own properties plus a prototype link. */
export class ProgramObject {
readonly props = new Map<PropertyKey, unknown>()
constructor(public proto: ProgramObject | null = null) {}
}
export class ProgramArray extends ProgramObject {
constructor(readonly items: Array<unknown> = []) {
super()
}
}
export class ProgramError extends ProgramObject {
constructor(readonly errorName: string) {
super()
}
}
export class ProgramFunction extends ProgramObject {
readonly length: number
constructor(
readonly name: string,
readonly parameters: ReadonlyArray<Pattern>,
readonly body: BlockStatement | Expression,
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
readonly async: boolean,
readonly generator: boolean,
) {
super()
const optional = parameters.findIndex((p) => p.type === "AssignmentPattern" || p.type === "RestElement")
this.length = optional === -1 ? parameters.length : optional
}
}
const MAX_ARRAY_LENGTH = 4_294_967_295
export const parseArrayIndex = (key: string | number): number | undefined => {
const property = String(key)
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
const index = Number(property)
return index < MAX_ARRAY_LENGTH ? index : undefined
}
const canonical = (key: PropertyKey): string | symbol => (typeof key === "symbol" ? key : String(key))
const index = (target: ProgramObject, key: string | symbol): number | undefined =>
target instanceof ProgramArray && typeof key === "string" ? parseArrayIndex(key) : undefined
// Non-enumerable built-in properties: array length, function name and length.
const builtin = (target: ProgramObject, name: string | symbol): boolean =>
(name === "length" && target instanceof ProgramArray) ||
((name === "name" || name === "length") && target instanceof ProgramFunction)
export const hasOwn = (target: ProgramObject, key: PropertyKey): boolean => {
const name = canonical(key)
const at = index(target, name)
if (at !== undefined) return at in (target as ProgramArray).items
return builtin(target, name) || target.props.has(name)
}
export const getOwn = (target: ProgramObject, key: PropertyKey): unknown => {
const name = canonical(key)
const at = index(target, name)
if (at !== undefined) return (target as ProgramArray).items[at]
if (target instanceof ProgramArray && name === "length") return target.items.length
if (target instanceof ProgramFunction && name === "name") return target.name
if (target instanceof ProgramFunction && name === "length") return target.length
return target.props.get(name)
}
export const get = (target: ProgramObject, key: PropertyKey): unknown => {
for (let current: ProgramObject | null = target; current !== null; current = current.proto) {
if (hasOwn(current, key)) return getOwn(current, key)
}
return undefined
}
export const has = (target: ProgramObject, key: PropertyKey): boolean => {
for (let current: ProgramObject | null = target; current !== null; current = current.proto) {
if (hasOwn(current, key)) return true
}
return false
}
export const set = (target: ProgramObject, key: PropertyKey, value: unknown): boolean => {
const name = canonical(key)
const at = index(target, name)
if (at !== undefined) {
;(target as ProgramArray).items[at] = value
return true
}
if (name === "length" && target instanceof ProgramArray) {
const length = typeof value === "number" ? value : Number(value)
if (!Number.isInteger(length) || length < 0 || length > 4_294_967_295) return false
target.items.length = length
return true
}
if (builtin(target, name)) return false
target.props.set(name, value)
return true
}
export const remove = (target: ProgramObject, key: PropertyKey): boolean => {
const name = canonical(key)
const at = index(target, name)
if (at !== undefined) return delete (target as ProgramArray).items[at]
if (name === "length" && target instanceof ProgramArray) return false
if (builtin(target, name)) return true
target.props.delete(name)
return true
}
// JS order: array indexes, integer-like keys ascending, other strings, then symbols.
export const ownKeys = (target: ProgramObject): Array<string | symbol> => {
const strings = [...target.props.keys()].filter((key): key is string => typeof key === "string")
const symbols = [...target.props.keys()].filter((key): key is symbol => typeof key === "symbol")
return [
...(target instanceof ProgramArray ? Object.keys(target.items) : []),
...strings.filter((key) => parseArrayIndex(key) !== undefined).sort((a, b) => Number(a) - Number(b)),
...strings.filter((key) => parseArrayIndex(key) === undefined),
...symbols,
]
}
export const ownEntries = (target: ProgramObject): Array<[string, unknown]> =>
ownKeys(target)
.filter((key): key is string => typeof key === "string")
.map((key) => [key, getOwn(target, key)])
export const record = (entries: Record<string, unknown>): ProgramObject => {
const target = new ProgramObject()
for (const [key, value] of Object.entries(entries)) set(target, key, value)
return target
}
export const assign = (target: ProgramObject, source: ProgramObject, skip?: ReadonlySet<PropertyKey>): void => {
for (const key of ownKeys(source)) {
if (skip?.has(key)) continue
if (typeof key === "symbol" && key !== IteratorSymbol && key !== AsyncIteratorSymbol) continue
set(target, key, getOwn(source, key))
}
}
+14 -23
View File
@@ -1,13 +1,7 @@
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import type { Diagnostic } from "../codemode.js"
import type { SafeObject } from "../data.js"
import {
type AstNode,
CodeModeFunction,
InterpreterRuntimeError,
ProgramThrow,
PromiseInstanceMethodReference,
} from "./model.js"
import { type AstNode, InterpreterRuntimeError, ProgramThrow, PromiseInstanceMethodReference } from "./model.js"
import { get, ProgramArray, ProgramFunction, ProgramObject, record } from "./objects.js"
import { HostFunction, requiresNew, sync } from "./host.js"
import { caughtErrorValue, normalizeError } from "./errors.js"
import { typeofValue } from "./references.js"
@@ -111,8 +105,8 @@ export const resolvePromiseValue = <R>(
): Effect.Effect<unknown, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
if (value instanceof Values.Promise) return runner.settlePromise(value)
if (value === null || typeof value !== "object" || !Object.hasOwn(value, "then")) return Effect.succeed(value)
const then = (value as SafeObject).then
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const then = get(value, "then")
if (typeofValue(then) !== "function") return Effect.succeed(value)
return Effect.gen(function* () {
@@ -174,10 +168,12 @@ const invokePromiseMethod = <R>(
}
if (name === "all") {
return yield* settleAfterTurn(
Effect.all(
items.map((item) => Effect.flatten(promises.await(item))),
{ concurrency: "unbounded" },
return new ProgramArray(
yield* settleAfterTurn(
Effect.all(
items.map((item) => Effect.flatten(promises.await(item))),
{ concurrency: "unbounded" },
),
),
)
}
@@ -186,19 +182,14 @@ const invokePromiseMethod = <R>(
for (const item of items) {
const exit = yield* promises.await(item)
if (Exit.isSuccess(exit)) {
outcomes.push(Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }))
outcomes.push(record({ status: "fulfilled", value: exit.value }))
continue
}
if (Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
outcomes.push(
Object.assign(Object.create(null) as SafeObject, {
status: "rejected",
reason: caughtErrorValue(Cause.squash(exit.cause)),
}),
)
outcomes.push(record({ status: "rejected", reason: caughtErrorValue(Cause.squash(exit.cause)) }))
}
yield* Effect.yieldNow
return outcomes
return new ProgramArray(outcomes)
}
if (name === "race") {
if (items.length === 0) {
@@ -253,7 +244,7 @@ const constructPromise = <R>(
executor: unknown,
node: AstNode,
): Effect.Effect<Values.Promise, unknown, R> => {
if (!(executor instanceof CodeModeFunction)) {
if (!(executor instanceof ProgramFunction)) {
throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
node,
@@ -3,20 +3,18 @@ import { Values } from "../values.js"
import { HostFunction, HostNamespace } from "./host.js"
import {
type AstNode,
AsyncIteratorSymbol,
CodeModeFunction,
CodeModeGenerator,
GeneratorMethodReference,
InterpreterRuntimeError,
IntrinsicReference,
IteratorSymbol,
PromiseInstanceMethodReference,
} from "./model.js"
import { getOwn, ownKeys, ProgramArray, ProgramFunction, ProgramObject } from "./objects.js"
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof HostFunction ||
value instanceof HostNamespace ||
value instanceof CodeModeFunction ||
value instanceof ProgramFunction ||
value instanceof CodeModeGenerator ||
value instanceof GeneratorMethodReference ||
value instanceof ToolReference ||
@@ -26,11 +24,8 @@ export const isRuntimeReference = (value: unknown): boolean =>
Values.isValue(value)
function* childValues(value: object): Generator {
for (const key of Reflect.ownKeys(value)) {
if (!Object.prototype.propertyIsEnumerable.call(value, key)) continue
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
yield Reflect.get(value, key)
}
if (!(value instanceof ProgramObject)) return
for (const key of ownKeys(value)) yield getOwn(value, key)
}
// Depth-first search over a value tree. `match` stops the walk; `skip` prunes a subtree without matching it.
@@ -79,7 +74,7 @@ export const rejectCircularInsertion = (
export const describeValue = (value: unknown): string => {
if (value === null) return "null"
if (Array.isArray(value)) return "an array"
if (value instanceof ProgramArray) return "an array"
if (value instanceof Values.Promise) return "an un-awaited Promise"
if (value instanceof ToolReference) return "a tool reference"
if (value instanceof Values.Date) return "a Date"
@@ -97,7 +92,7 @@ export const describeValue = (value: unknown): string => {
export const typeofValue = (value: unknown): string => {
if (
value instanceof HostFunction ||
value instanceof CodeModeFunction ||
value instanceof ProgramFunction ||
value instanceof GeneratorMethodReference ||
value instanceof IntrinsicReference ||
value instanceof PromiseInstanceMethodReference
@@ -108,12 +103,3 @@ export const typeofValue = (value: unknown): string => {
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
return typeof value
}
const MAX_ARRAY_LENGTH = 4_294_967_295
export const parseArrayIndex = (key: string | number): number | undefined => {
const property = String(key)
if (!/^(0|[1-9]\d*)$/.test(property)) return undefined
const index = Number(property)
return index < MAX_ARRAY_LENGTH ? index : undefined
}
+10 -8
View File
@@ -2,7 +2,8 @@ import { Effect, Exit } from "effect"
import { Values } from "../values.js"
import { coerceToString } from "../stdlib/value.js"
import { HostFunction } from "./host.js"
import { type AstNode, CodeModeFunction, InterpreterRuntimeError, IntrinsicReference } from "./model.js"
import { type AstNode, InterpreterRuntimeError, IntrinsicReference } from "./model.js"
import { get, has, ProgramFunction, ProgramObject } from "./objects.js"
import { typeofValue } from "./references.js"
export type IteratorCursor<R> = {
@@ -12,7 +13,7 @@ export type IteratorCursor<R> = {
/** Everything a host function needs to call back into the program. */
export type Runner<R> = {
readonly invokeFunction: (fn: CodeModeFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly invokeFunction: (fn: ProgramFunction, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly invokeCallable: (
callable: unknown,
args: Array<unknown>,
@@ -42,13 +43,14 @@ export const toPrimitive = <R>(
if (Values.isValue(value)) {
return Effect.succeed(value instanceof Values.Date && hint === "number" ? value.time : coerceToString(value))
}
const object = value as Record<string, unknown>
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const order = hint === "number" ? ["valueOf", "toString"] : ["toString", "valueOf"]
return Effect.gen(function* () {
for (const method of order) {
if (method === "toString" && !Object.hasOwn(object, "toString")) return coerceToString(value)
if (!Object.hasOwn(object, method) || typeofValue(object[method]) !== "function") continue
const result = yield* runner.invokeCallable(object[method], [], node)
if (method === "toString" && !has(value, "toString")) return coerceToString(value)
const callable = get(value, method)
if (typeofValue(callable) !== "function") continue
const result = yield* runner.invokeCallable(callable, [], node)
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node).as("TypeError")
@@ -59,10 +61,10 @@ export const toPrimitive = <R>(
// Array.from mappers, and promise reactions all admit exactly these callables.
// Admission means dispatchable, not necessarily invocable: new-requiring
// constructors pass the gate and throw a TypeError on call, like JS.
export type SupportedCallback = CodeModeFunction | HostFunction<unknown> | IntrinsicReference
export type SupportedCallback = ProgramFunction | HostFunction<unknown> | IntrinsicReference
export const isSupportedCallback = (value: unknown): value is SupportedCallback =>
value instanceof CodeModeFunction ||
value instanceof ProgramFunction ||
(value instanceof HostFunction && value.callback) ||
value instanceof IntrinsicReference
+159 -152
View File
@@ -1,6 +1,7 @@
import type {
ArrayExpression,
ArrayPattern,
AssignmentPattern,
ArrowFunctionExpression,
AssignmentExpression,
AssignmentProperty,
@@ -41,13 +42,12 @@ import type {
YieldExpression,
} from "acorn"
import { Cause, Deferred, Effect, Exit } from "effect"
import { ToolRuntimeError, type SafeObject, toProgram } from "../data.js"
import { ToolRuntimeError, toProgram } from "../data.js"
import { ToolReference } from "../tool-runtime.js"
import {
type AstNode,
AsyncIteratorSymbol,
type Binding,
CodeModeFunction,
CodeModeGenerator,
ComputedValue,
GeneratorMethodReference,
@@ -55,9 +55,7 @@ import {
type GeneratorRequestKind,
IntrinsicReference,
InterpreterRuntimeError,
isRecord,
IteratorSymbol,
IteratorSymbols,
type MemberReference,
OptionalShortCircuit,
PromiseInstanceMethodReference,
@@ -66,16 +64,28 @@ import {
unsupportedSyntax,
} from "./model.js"
import { caughtErrorValue } from "./errors.js"
import { globals } from "./globals.js"
import { globals, type Host } from "./globals.js"
import { HostFunction, HostNamespace } from "./host.js"
import { invokeIntrinsic } from "./methods.js"
import {
assign,
get,
has,
ownKeys,
parseArrayIndex,
ProgramArray,
ProgramFunction,
ProgramObject,
record,
remove,
set,
} from "./objects.js"
import { preserveConsumerError, type Runner } from "./runner.js"
import { invokePromiseInstanceMethod, PromiseRuntime, resolvePromise, resolvePromiseValue } from "./promises.js"
import {
containsOpaqueReference,
describeValue,
isRuntimeReference,
parseArrayIndex,
rejectCircularInsertion,
typeofValue,
} from "./references.js"
@@ -118,14 +128,11 @@ const calleeDescription = (callee: Expression | Super | undefined): string => {
return "The called value"
}
const hasOwn = (value: unknown, key: PropertyKey): boolean =>
value !== null && typeof value === "object" && Object.hasOwn(value, key)
const constructorName = (value: unknown): string | undefined => {
if (typeof value === "string") return "String"
if (typeof value === "number") return "Number"
if (typeof value === "boolean") return "Boolean"
if (Array.isArray(value)) return "Array"
if (value instanceof ProgramArray) return "Array"
if (value instanceof Values.Date) return "Date"
if (value instanceof Values.RegExp) return "RegExp"
if (value instanceof Values.Map) return "Map"
@@ -133,7 +140,7 @@ const constructorName = (value: unknown): string | undefined => {
if (value instanceof Values.URL) return "URL"
if (value instanceof Values.URLSearchParams) return "URLSearchParams"
if (value instanceof Values.Promise) return "Promise"
if (value === null || typeof value !== "object" || isRuntimeReference(value)) return undefined
if (!(value instanceof ProgramObject) || value instanceof ProgramFunction) return undefined
return errorBrandName(value) ?? "Object"
}
@@ -230,7 +237,7 @@ const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...
}
type CustomIterator = {
iterator: SafeObject | CodeModeGenerator
iterator: ProgramObject | CodeModeGenerator
next: unknown
asynchronous: boolean
}
@@ -247,13 +254,6 @@ const isOpaqueMemberReference = (value: unknown): value is OpaqueMemberReference
value instanceof IntrinsicReference ||
value instanceof GeneratorMethodReference
const copyIteratorSymbols = (source: object, target: object, consumed?: ReadonlySet<PropertyKey>): void => {
for (const symbol of IteratorSymbols) {
if (!consumed?.has(symbol) && Object.hasOwn(source, symbol))
Reflect.set(target, symbol, Reflect.get(source, symbol))
}
}
type GeneratorRequest = {
kind: GeneratorRequestKind
value: unknown
@@ -285,6 +285,7 @@ export class Runtime<R> {
readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
readonly promises: PromiseRuntime<R>,
readonly logs: Array<string> = [],
extraGlobals: (host: Host<R>) => ReadonlyArray<readonly [string, unknown]> = () => [],
) {
const globalScope = new Map<string, Binding>()
// Calling back into the program never reads frame state, so any frame serves; the root is always alive.
@@ -295,7 +296,7 @@ export class Runtime<R> {
settlePromise: (promise) => this.root.settlePromise(promise),
syncIterator: (value, node) => this.root.syncIterator(value, node),
}
this.builtins = new Map(globals(this))
this.builtins = new Map([...globals(this), ...extraGlobals(this)])
for (const [name, value] of this.builtins) globalScope.set(name, { mutable: false, value })
}
@@ -434,8 +435,19 @@ class Frame<R> {
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
}
private createFunction(node: FunctionDeclaration | FunctionExpression | ArrowFunctionExpression): CodeModeFunction {
return new CodeModeFunction(node.params, node.body, this.scopes.capture(), node.async, node.generator)
private createFunction(
node: FunctionDeclaration | FunctionExpression | ArrowFunctionExpression,
name = node.type === "ArrowFunctionExpression" ? "" : (node.id?.name ?? ""),
): ProgramFunction {
return new ProgramFunction(name, node.params, node.body, this.scopes.capture(), node.async, node.generator)
}
// NamedEvaluation: an anonymous function definition takes the name of what it is assigned to.
private evaluateNamed(node: Expression, name: string): Effect.Effect<unknown, unknown, R> {
if (node.type === "ArrowFunctionExpression" || (node.type === "FunctionExpression" && !node.id)) {
return Effect.sync(() => this.createFunction(node, name))
}
return this.evaluateExpression(node)
}
private hoistFunctions(statements: ReadonlyArray<Statement | ModuleDeclaration>): void {
@@ -722,22 +734,26 @@ class Frame<R> {
}
syncIterator(value: unknown, node: AstNode) {
const iterator = Array.isArray(value)
? value[Symbol.iterator]()
: typeof value === "string"
? value[Symbol.iterator]()
: value instanceof Values.Map
? value.map.entries()
: value instanceof Values.Set
? value.set.values()
: value instanceof Values.URLSearchParams
? value.params.entries()
: undefined
const iterator =
value instanceof ProgramArray
? value.items[Symbol.iterator]()
: typeof value === "string"
? value[Symbol.iterator]()
: value instanceof Values.Map
? value.map.entries()
: value instanceof Values.Set
? value.set.values()
: value instanceof Values.URLSearchParams
? value.params.entries()
: undefined
if (iterator !== undefined) {
return Effect.succeed({
next: Effect.sync(() => {
const step = iterator.next()
return { done: Boolean(step.done), value: step.value }
return {
done: Boolean(step.done),
value: Array.isArray(step.value) ? new ProgramArray(step.value) : step.value,
}
}),
close: Effect.void,
})
@@ -762,9 +778,9 @@ class Frame<R> {
asynchronous: value.asynchronous,
})
}
if (!isRecord(value) || isRuntimeReference(value)) return Effect.undefined
const asyncMethod = allowAsync ? Reflect.get(value, AsyncIteratorSymbol) : undefined
const method = asyncMethod ?? Reflect.get(value, IteratorSymbol)
if (!(value instanceof ProgramObject)) return Effect.undefined
const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined
const method = asyncMethod ?? get(value, IteratorSymbol)
if (method === undefined || method === null) return Effect.undefined
const self = this
return Effect.map(
@@ -776,7 +792,7 @@ class Frame<R> {
next:
object instanceof CodeModeGenerator
? new GeneratorMethodReference(object, "next")
: self.requireIteratorMethod(object.next, "Iterator next", node),
: self.requireIteratorMethod(get(object, "next"), "Iterator next", node),
asynchronous: asyncMethod !== undefined && asyncMethod !== null,
}
},
@@ -792,7 +808,7 @@ class Frame<R> {
"Iterator next() result",
node,
)
return { done: Boolean(object.done), value: object.value }
return { done: Boolean(get(object, "done")), value: get(object, "value") }
}
const called = yield* Effect.exit(self.invokeCallable(iterator.next, [], node))
@@ -803,7 +819,7 @@ class Frame<R> {
const captured = yield* Effect.exit(
Effect.sync(() => {
const object = self.requireIteratorObject(called.value, "Iterator next() result", node)
return { done: Boolean(object.done), value: object.value }
return { done: Boolean(get(object, "done")), value: get(object, "value") }
}),
)
if (!Exit.isSuccess(captured)) {
@@ -823,7 +839,7 @@ class Frame<R> {
const close =
iterator.iterator instanceof CodeModeGenerator
? new GeneratorMethodReference(iterator.iterator, "return")
: iterator.iterator.return
: get(iterator.iterator, "return")
if (close === undefined || close === null) return iterator.asynchronous || !awaiting ? Effect.void : Effect.yieldNow
const self = this
return Effect.gen(function* () {
@@ -843,7 +859,7 @@ class Frame<R> {
return yield* Effect.failCause(called.cause)
}
const captured = yield* Effect.exit(
Effect.sync(() => self.requireIteratorObject(called.value, "Iterator return() result", node).value),
Effect.sync(() => get(self.requireIteratorObject(called.value, "Iterator return() result", node), "value")),
)
if (!Exit.isSuccess(captured)) {
if (awaiting) yield* Effect.yieldNow
@@ -853,12 +869,12 @@ class Frame<R> {
})
}
private requireIteratorObject(value: unknown, context: string, node: AstNode): SafeObject {
if (isRecord(value) && !isRuntimeReference(value)) return value
private requireIteratorObject(value: unknown, context: string, node: AstNode): ProgramObject {
if (value instanceof ProgramObject) return value
throw new InterpreterRuntimeError(`${context} must be an object.`, node).as("TypeError")
}
private requireIterator(value: unknown, node: AstNode): SafeObject | CodeModeGenerator {
private requireIterator(value: unknown, node: AstNode): ProgramObject | CodeModeGenerator {
return value instanceof CodeModeGenerator
? value
: this.requireIteratorObject(value, "Iterator method result", node)
@@ -873,7 +889,7 @@ class Frame<R> {
private enumerableKeys(value: unknown, node: AstNode): Array<string> {
if (value instanceof ToolReference) return [...this.runtime.toolKeys(value.path)]
if (value === null || value === undefined) return []
return Object.keys(enumerableSource("for...in", value, node))
return ownKeys(enumerableSource("for...in", value, node)).filter((key): key is string => typeof key === "string")
}
private evaluateForInStatement(
@@ -1019,11 +1035,14 @@ class Frame<R> {
const init = declaration.init
// `var x` alone is a no-op: the binding was hoisted on function entry.
const id = declaration.id
const evaluate = (init: Expression) =>
id.type === "Identifier" ? self.evaluateNamed(init, id.name) : self.evaluateExpression(init)
if (kind === "var") {
if (init) yield* self.assignPattern(declaration.id, yield* self.evaluateExpression(init), declaration)
if (init) yield* self.assignPattern(id, yield* evaluate(init), declaration)
continue
}
const value = init ? yield* self.evaluateExpression(init) : undefined
const value = init ? yield* evaluate(init) : undefined
yield* self.declarePattern(declaration.id, value, kind !== "const", declaration, true)
}
})
@@ -1046,13 +1065,13 @@ class Frame<R> {
}
if (pattern.type === "AssignmentPattern") {
const resolved = value === undefined ? yield* self.evaluateExpression(pattern.right) : value
const resolved = value === undefined ? yield* self.evaluateDefault(pattern) : value
yield* self.declarePattern(pattern.left, resolved, mutable, node, initialize)
return
}
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
if (!(value instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
@@ -1063,11 +1082,8 @@ class Frame<R> {
const consumed = new Set<PropertyKey>()
for (const property of pattern.properties) {
if (property.type === "RestElement") {
const rest: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key)) rest[key] = item
}
copyIteratorSymbols(value, rest, consumed)
const rest = new ProgramObject()
assign(rest, value, consumed)
yield* self.declarePattern(property.argument, rest, mutable, property, initialize)
continue
}
@@ -1076,7 +1092,7 @@ class Frame<R> {
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
property.value,
self.destructuringPropertyValue(value as SafeObject | Array<unknown>, key),
self.destructuringPropertyValue(value, key),
mutable,
property,
initialize,
@@ -1109,13 +1125,13 @@ class Frame<R> {
}
if (pattern.type === "AssignmentPattern") {
const resolved = value === undefined ? yield* self.evaluateExpression(pattern.right) : value
const resolved = value === undefined ? yield* self.evaluateDefault(pattern) : value
yield* self.assignPattern(pattern.left, resolved, node)
return
}
if (pattern.type === "ObjectPattern") {
if (value === null || typeof value !== "object" || isRuntimeReference(value)) {
if (!(value instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
@@ -1123,21 +1139,17 @@ class Frame<R> {
)
}
const source = value as SafeObject | Array<unknown>
const consumed = new Set<PropertyKey>()
for (const property of pattern.properties) {
if (property.type === "RestElement") {
const rest: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key)) rest[key] = item
}
copyIteratorSymbols(source, rest, consumed)
const rest = new ProgramObject()
assign(rest, value, 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.destructuringPropertyValue(source, key), property)
yield* self.assignPattern(property.value, self.destructuringPropertyValue(value, key), property)
}
return
}
@@ -1152,6 +1164,12 @@ class Frame<R> {
})
}
private evaluateDefault(pattern: AssignmentPattern): Effect.Effect<unknown, unknown, R> {
return pattern.left.type === "Identifier"
? this.evaluateNamed(pattern.right, pattern.left.name)
: this.evaluateExpression(pattern.right)
}
private destructureArrayPattern(
pattern: ArrayPattern,
value: unknown,
@@ -1171,7 +1189,7 @@ class Frame<R> {
if (element === null) continue
yield* consume(
element.type === "RestElement" ? element.argument : element,
element.type === "RestElement" ? [] : undefined,
element.type === "RestElement" ? new ProgramArray() : undefined,
element,
)
if (element.type === "RestElement") return
@@ -1188,7 +1206,7 @@ class Frame<R> {
done = next.done
if (!done) rest.push(next.value)
}
yield* consume(element.argument, rest, element)
yield* consume(element.argument, new ProgramArray(rest), element)
return
}
const consumed = consume(element, step.done ? undefined : step.value, pattern)
@@ -1211,11 +1229,9 @@ class Frame<R> {
throw unsupportedSyntax(keyNode.type, keyNode)
}
private destructuringPropertyValue(source: SafeObject | Array<unknown>, key: PropertyKey): unknown {
if (!Array.isArray(source)) return Reflect.get(source, key)
if (key === "length") return source.length
if (typeof key === "number") return source[key]
if (Object.hasOwn(source, key)) return Reflect.get(source, key)
private destructuringPropertyValue(source: ProgramObject, key: PropertyKey): unknown {
if (!(source instanceof ProgramArray)) return get(source, key)
if (has(source, key)) return get(source, key)
if (typeof key === "string" && arrayMethods.has(key)) return new IntrinsicReference(source, key)
return undefined
}
@@ -1293,7 +1309,7 @@ class Frame<R> {
// otherwise; say `new` is unsupported for them and point at the plain call.
const name = calleeDescription(node.callee)
const message =
callee instanceof CodeModeFunction
callee instanceof ProgramFunction
? `${name} cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.`
: callee instanceof HostFunction
? `new ${name}(...) is not supported; call ${name}(...) without new instead.`
@@ -1321,6 +1337,9 @@ class Frame<R> {
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
if (operator === "===") return lhs === rhs
if (operator === "!==") return lhs !== rhs
if (operator === "in" && rhs instanceof ProgramObject && !containsOpaqueReference(lhs)) {
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
}
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
throw new InterpreterRuntimeError("Binary operators require data values.", node, "InvalidDataValue")
}
@@ -1373,11 +1392,10 @@ class Frame<R> {
case ">>>":
return (l as number) >>> (r as number)
case "in":
if (rhs === null || typeof rhs !== "object") {
if (!(rhs instanceof ProgramObject)) {
throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node)
}
// Never expose properties inherited from host prototypes.
return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey)
return has(rhs, coerceOperand(lhs) as PropertyKey)
default:
throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
}
@@ -1454,7 +1472,7 @@ class Frame<R> {
const next = toProgram(self.applyCompoundAssignment(operator, current, rightValue, node), "Assignment result")
return self.scopes.set(name, next, left)
}
const rightValue = yield* self.evaluateExpression(node.right)
const rightValue = yield* self.evaluateNamed(node.right, name)
return self.scopes.set(name, rightValue, left)
}
if (left.type === "MemberExpression") {
@@ -1486,7 +1504,7 @@ class Frame<R> {
return Effect.gen(function* () {
const current = self.scopes.get(name, left)
if (!shouldAssign(current)) return current
const rightValue = yield* self.evaluateExpression(node.right)
const rightValue = yield* self.evaluateNamed(node.right, name)
return self.scopes.set(name, rightValue, left)
})
}
@@ -1575,7 +1593,7 @@ class Frame<R> {
}
return yield* self.createToolCallPromise(callable.path, args)
}
if (callable instanceof CodeModeFunction) {
if (callable instanceof ProgramFunction) {
return yield* self.invokeFunction(callable, args)
}
if (callable instanceof GeneratorMethodReference) {
@@ -1626,7 +1644,7 @@ class Frame<R> {
})
}
invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
invokeFunction(fn: ProgramFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
const invocation = new Frame(this.runtime, new ScopeStack([...fn.capturedScopes, new Map()]))
const run = Effect.gen(function* () {
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
@@ -1638,7 +1656,13 @@ class Frame<R> {
}
for (const [index, parameter] of fn.parameters.entries()) {
if (parameter.type === "RestElement") {
yield* invocation.declarePattern(parameter.argument, args.slice(index), true, parameter, true)
yield* invocation.declarePattern(
parameter.argument,
new ProgramArray(args.slice(index)),
true,
parameter,
true,
)
break
}
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
@@ -1690,12 +1714,12 @@ class Frame<R> {
}
if (state.completed) {
if (kind === "throw") return Effect.fail(new ProgramThrow(value))
return Effect.succeed({ value: kind === "return" ? value : undefined, done: true })
return Effect.succeed(record({ value: kind === "return" ? value : undefined, done: true }))
}
if (!state.started && kind !== "next") {
state.completed = true
if (kind === "throw") return Effect.fail(new ProgramThrow(value))
return Effect.succeed({ value, done: true })
return Effect.succeed(record({ value, done: true }))
}
state.pending.push(request)
@@ -1725,7 +1749,7 @@ class Frame<R> {
if (active) {
Deferred.doneUnsafe(
active.response,
Exit.isSuccess(exit) ? Exit.succeed({ value: exit.value, done: true }) : exit,
Exit.isSuccess(exit) ? Exit.succeed(record({ value: exit.value, done: true })) : exit,
)
}
yield* invocation.completeGeneratorRequests(state, asynchronous)
@@ -1752,13 +1776,13 @@ class Frame<R> {
const resolved = yield* Effect.exit(self.awaitValue(pending.value))
Deferred.doneUnsafe(
pending.response,
Exit.isSuccess(resolved) ? Exit.succeed({ value: resolved.value, done: true }) : resolved,
Exit.isSuccess(resolved) ? Exit.succeed(record({ value: resolved.value, done: true })) : resolved,
)
continue
}
Deferred.doneUnsafe(
pending.response,
Exit.succeed({ value: pending.kind === "return" ? pending.value : undefined, done: true }),
Exit.succeed(record({ value: pending.kind === "return" ? pending.value : undefined, done: true })),
)
}
})
@@ -1803,7 +1827,7 @@ class Frame<R> {
private suspendGenerator(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
const state = this.generatorState
if (!state?.active) throw new InterpreterRuntimeError("Generator has no active request.", node)
Deferred.doneUnsafe(state.active.response, Exit.succeed({ value, done: false }))
Deferred.doneUnsafe(state.active.response, Exit.succeed(record({ value, done: false })))
state.active = undefined
return Effect.flatMap(this.takeGeneratorRequest(state), (request) => {
state.active = request
@@ -1819,7 +1843,7 @@ class Frame<R> {
const self = this
return Effect.gen(function* () {
if (
Array.isArray(value) ||
value instanceof ProgramArray ||
typeof value === "string" ||
value instanceof Values.Map ||
value instanceof Values.Set ||
@@ -1860,7 +1884,7 @@ class Frame<R> {
? iterator.next
: iterator.iterator instanceof CodeModeGenerator
? new GeneratorMethodReference(iterator.iterator, kind)
: iterator.iterator[kind]
: get(iterator.iterator, kind)
if (method === undefined || method === null) {
if (kind === "return") return yield* Effect.fail(new GeneratorReturn(input))
yield* self.closeIterator(iterator, node, self.generatorAsync)
@@ -1878,11 +1902,11 @@ class Frame<R> {
`Iterator ${kind}() result`,
node,
)
const done = Boolean(result.done)
const done = Boolean(get(result, "done"))
const resultValue: unknown =
self.generatorAsync && !iterator.asynchronous
? yield* self.awaitAsyncFromSyncValue(iterator, result.value, node, kind !== "return" && !done)
: result.value
? yield* self.awaitAsyncFromSyncValue(iterator, get(result, "value"), node, kind !== "return" && !done)
: get(result, "value")
if (done) {
if (kind === "return") return yield* Effect.fail(new GeneratorReturn(resultValue))
return resultValue
@@ -1904,17 +1928,15 @@ class Frame<R> {
})
}
private evaluateObjectExpression(node: ObjectExpression): Effect.Effect<Record<string, unknown>, unknown, R> {
const objectValue: Record<string, unknown> = Object.create(null) as Record<string, unknown>
private evaluateObjectExpression(node: ObjectExpression): Effect.Effect<ProgramObject, unknown, R> {
const objectValue = new ProgramObject()
const self = this
return Effect.gen(function* () {
for (const property of node.properties) {
if (property.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(property.argument)
if (spread === null || spread === undefined) continue
const from = enumerableSource("Object spread", spread, property)
for (const [key, value] of Object.entries(from)) objectValue[key] = value
if (typeof from === "object") copyIteratorSymbols(from, objectValue)
assign(objectValue, enumerableSource("Object spread", spread, property))
continue
}
@@ -1936,14 +1958,20 @@ class Frame<R> {
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
}
Reflect.set(objectValue, key, yield* self.evaluateExpression(property.value))
const name =
key === IteratorSymbol
? "[Symbol.iterator]"
: key === AsyncIteratorSymbol
? "[Symbol.asyncIterator]"
: String(key)
set(objectValue, key, yield* self.evaluateNamed(property.value, name))
}
return objectValue
})
}
private evaluateArrayExpression(node: ArrayExpression): Effect.Effect<Array<unknown>, unknown, R> {
private evaluateArrayExpression(node: ArrayExpression): Effect.Effect<ProgramArray, unknown, R> {
const values: Array<unknown> = []
const self = this
@@ -1968,7 +1996,7 @@ class Frame<R> {
values.push(yield* self.evaluateExpression(element))
}
}
return values
return new ProgramArray(values)
})
}
@@ -2055,7 +2083,11 @@ class Frame<R> {
}
// Values have no prototype chain, so `.constructor` resolves to the owning built-in directly.
if (operation === "read" && key === "constructor" && !hasOwn(objectValue, key)) {
if (
operation === "read" &&
key === "constructor" &&
!(objectValue instanceof ProgramObject && has(objectValue, key))
) {
const name = constructorName(objectValue)
if (name !== undefined) return new ComputedValue(self.runtime.builtins.get(name))
}
@@ -2136,6 +2168,8 @@ class Frame<R> {
return new ComputedValue(undefined)
}
if (objectValue instanceof ProgramObject) return { target: objectValue, key }
if (isRuntimeReference(objectValue)) {
throw new InterpreterRuntimeError(
`Cannot read properties of ${describeValue(objectValue)}; only data values expose properties.`,
@@ -2143,42 +2177,27 @@ class Frame<R> {
"InvalidDataValue",
)
}
if (typeof objectValue !== "object" || objectValue === null) {
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
}
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (key !== "length" && !(typeof key === "string" && arrayMethods.has(key)) && index === undefined) {
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
}
return new ComputedValue(undefined)
}
return { target: objectValue, key: index ?? key }
}
return { target: objectValue as SafeObject, key }
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
})
}
private readMember(node: MemberExpression): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.map(this.getMemberReference(node), (reference) => {
if (reference === OptionalShortCircuit) return OptionalShortCircuit
if (reference instanceof ComputedValue) return reference.value
if (reference === undefined || isOpaqueMemberReference(reference)) return reference
if (Array.isArray(reference.target)) {
if (reference.key === "length") return reference.target.length
if (typeof reference.key === "string") return new IntrinsicReference(reference.target, reference.key)
return Reflect.get(reference.target, reference.key)
const value = self.readReferenceValue(reference, reference.key)
if (
value === undefined &&
reference.target instanceof ProgramArray &&
typeof reference.key === "string" &&
arrayMethods.has(reference.key) &&
!has(reference.target, reference.key)
) {
return new IntrinsicReference(reference.target, reference.key)
}
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
if (reference.target instanceof Values.URL) {
return Reflect.get(reference.target.url, reference.key)
}
return Reflect.get(reference.target, reference.key)
return value
})
}
@@ -2204,7 +2223,7 @@ class Frame<R> {
if (reference.target instanceof Values.RegExp) {
return Reflect.deleteProperty(reference.target.regex, reference.key)
}
return Reflect.deleteProperty(reference.target, reference.key)
return remove(reference.target, reference.key)
})
}
@@ -2224,12 +2243,6 @@ class Frame<R> {
) {
throw new InterpreterRuntimeError("Only data fields may be assigned.", node)
}
if (Array.isArray(reference.target)) {
if (reference.key === "length") throw new InterpreterRuntimeError("Array length cannot be assigned.", node)
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
throw new InterpreterRuntimeError("Array methods cannot be assigned.", node)
}
}
const key = reference.key
const { write, next, result } = yield* compute(self.readReferenceValue(reference, key))
if (write) self.assignToReference(reference, key, next, node)
@@ -2242,23 +2255,10 @@ class Frame<R> {
return Reflect.get(reference.target.url, key)
}
if (reference.target instanceof Values.RegExp) return reference.target.lastIndex
return Reflect.get(reference.target, key)
return get(reference.target, key)
}
private assignToReference(reference: MemberReference, key: PropertyKey, next: unknown, node: AstNode): void {
if (Array.isArray(reference.target)) {
const target = reference.target
if (typeof key !== "number" || parseArrayIndex(key) === undefined) {
throw new InterpreterRuntimeError(
"Array assignment index must be a valid array index.",
node,
"InvalidDataValue",
)
}
rejectCircularInsertion(target, next, "Array assignment result", node)
target[key] = next
return
}
if (reference.target instanceof Values.URL) {
const property = key as string
if (!urlWritableProperties.has(property)) {
@@ -2277,9 +2277,16 @@ class Frame<R> {
reference.target.lastIndex = next
return
}
const target = reference.target as SafeObject
rejectCircularInsertion(target, next, "Object assignment result", node)
Reflect.set(target, key, next)
const target = reference.target
rejectCircularInsertion(
target,
next,
target instanceof ProgramArray ? "Array assignment result" : "Object assignment result",
node,
)
if (set(target, key, next)) return
if (target instanceof ProgramArray) throw new InterpreterRuntimeError("Invalid array length", node).as("RangeError")
throw new InterpreterRuntimeError(`Cannot assign to read only property '${String(key)}'.`, node).as("TypeError")
}
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
+17 -18
View File
@@ -1,28 +1,27 @@
import { Effect } from "effect"
import { HostFunction, sync, syncCall } from "../interpreter/host.js"
import { type AstNode, CodeModeGenerator, InterpreterRuntimeError } from "../interpreter/model.js"
import { get, ProgramArray, ProgramObject } from "../interpreter/objects.js"
import { describeValue } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError, type Runner } from "../interpreter/runner.js"
const constructArray = (args: Array<unknown>, node: AstNode): Array<unknown> => {
if (args.length !== 1) return [...args]
const constructArray = (args: Array<unknown>, node: AstNode): ProgramArray => {
if (args.length !== 1) return new ProgramArray([...args])
const first = args[0]
if (typeof first !== "number") return [first]
if (typeof first !== "number") return new ProgramArray([first])
if (!Number.isInteger(first) || first < 0 || first > 4294967295) {
throw new InterpreterRuntimeError("Invalid array length.", node).as("RangeError")
}
// Sparse like JS: Array(3) has holes, and combinator loops already skip them.
return new Array(first)
return new ProgramArray(new Array(first))
}
const arrayLikeSource = (source: unknown, node: AstNode): { readonly length: number; readonly source: object } => {
if (
source !== null &&
typeof source === "object" &&
(Object.getPrototypeOf(source) === Object.prototype || Object.getPrototypeOf(source) === null) &&
typeof (source as { length?: unknown }).length === "number"
) {
const length = (source as { length: number }).length
const arrayLikeSource = (
source: unknown,
node: AstNode,
): { readonly length: number; readonly source: ProgramObject } => {
if (source instanceof ProgramObject && typeof get(source, "length") === "number") {
const length = get(source, "length") as number
const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length)
if (normalized > 4_294_967_295) throw new RangeError("Invalid array length")
return { length: normalized, source }
@@ -49,16 +48,16 @@ const arrayFrom = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): E
const arrayLike = arrayLikeSource(source, node)
const values: Array<unknown> = []
for (let index = 0; index < arrayLike.length; index += 1) {
const item = Reflect.get(arrayLike.source, index)
const item = get(arrayLike.source, index)
values.push(apply === undefined ? item : yield* apply([item, index]))
}
return values
return new ProgramArray(values)
}
const values: Array<unknown> = []
let index = 0
while (true) {
const step = yield* cursor.next
if (step.done) return values
if (step.done) return new ProgramArray(values)
values.push(apply === undefined ? step.value : yield* preserveConsumerError(cursor, apply([step.value, index])))
index += 1
}
@@ -71,10 +70,10 @@ export const arrayGlobal = <R>(runner: Runner<R>) =>
name: "Array",
call: syncCall(constructArray),
construct: syncCall(constructArray),
instanceOf: (value) => Array.isArray(value),
instanceOf: (value) => value instanceof ProgramArray,
members: {
isArray: sync("Array.isArray", (args) => Array.isArray(args[0])),
of: sync("Array.of", (args) => [...args]),
isArray: sync("Array.isArray", (args) => args[0] instanceof ProgramArray),
of: sync("Array.of", (args) => new ProgramArray([...args])),
from: new HostFunction<R>({ name: "Array.from", call: (args, node) => arrayFrom(runner, args, node) }),
},
})
+10 -10
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { SafeObject } from "../data.js"
import { HostFunction, requiresNew } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError, isRecord } from "../interpreter/model.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { getOwn, ProgramArray, ProgramObject, set } from "../interpreter/objects.js"
import { describeValue, isRuntimeReference } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError, type Runner, toPrimitive } from "../interpreter/runner.js"
import { Values } from "../values.js"
@@ -108,13 +108,13 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
const item = step.value
const key = yield* preserveConsumerError(cursor, apply([item, index]))
const group = result.map.get(key)
if (group === undefined) result.map.set(key, [item])
else (group as Array<unknown>).push(item)
if (group === undefined) result.map.set(key, new ProgramArray([item]))
else (group as ProgramArray).items.push(item)
index += 1
}
}
const result: SafeObject = Object.create(null) as SafeObject
const result = new ProgramObject()
let index = 0
while (true) {
const step = yield* cursor.next
@@ -124,9 +124,9 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
cursor,
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
)
const group = result[key]
if (group === undefined) result[key] = [item]
else (group as Array<unknown>).push(item)
const group = getOwn(result, key)
if (group === undefined) set(result, key, new ProgramArray([item]))
else (group as ProgramArray).items.push(item)
index += 1
}
})
@@ -150,12 +150,12 @@ const constructMap = <R>(runner: Runner<R>, init: unknown, node: AstNode) => {
yield* preserveConsumerError(
cursor,
Effect.sync(() => {
if (!isRecord(step.value) || isRuntimeReference(step.value)) {
if (!(step.value instanceof ProgramObject)) {
throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs as entry objects.", node).as(
"TypeError",
)
}
target.map.set(step.value[0], step.value[1])
target.map.set(getOwn(step.value, 0), getOwn(step.value, 1))
}),
)
}
+15 -14
View File
@@ -1,5 +1,6 @@
import { toData, toProgram } from "../data.js"
import { HostNamespace, sync } from "../interpreter/host.js"
import { get, ownEntries, ProgramArray, ProgramObject } from "../interpreter/objects.js"
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
import { Values } from "../values.js"
import { coerceToString } from "./value.js"
@@ -51,8 +52,8 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (value instanceof Values.Map) {
seen.add(value)
try {
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
return `Map(${value.map.size}) ${formatConsoleValue(entries, seen, depth + 1)}`
const entries = Array.from(value.map.entries(), ([key, item]) => new ProgramArray([key, item]))
return `Map(${value.map.size}) ${formatConsoleValue(new ProgramArray(entries), seen, depth + 1)}`
} finally {
seen.delete(value)
}
@@ -60,7 +61,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (value instanceof Values.Set) {
seen.add(value)
try {
return `Set(${value.set.size}) ${formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
return `Set(${value.set.size}) ${formatConsoleValue(new ProgramArray([...value.set.values()]), seen, depth + 1)}`
} finally {
seen.delete(value)
}
@@ -68,10 +69,11 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (isRuntimeReference(value)) return "[opaque reference]"
seen.add(value)
try {
if (Array.isArray(value)) {
return `[${value.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]`
if (value instanceof ProgramArray) {
return `[${value.items.map((item) => formatConsoleValue(item, seen, depth + 1)).join(",")}]`
}
return `{${Object.entries(value)
if (!(value instanceof ProgramObject)) return "[object Object]"
return `{${ownEntries(value)
.map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`)
.join(",")}}`
} finally {
@@ -104,20 +106,19 @@ const consoleTableRows = (
data: unknown,
columns: ReadonlyArray<string> | undefined,
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> => {
if (Array.isArray(data)) {
return data.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
if (data instanceof ProgramArray) {
return data.items.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
}
if (data !== null && typeof data === "object" && !Values.isValue(data)) {
return Object.entries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
if (data instanceof ProgramObject) {
return ownEntries(data).map(([index, item]) => ({ index, values: consoleTableValues(item, columns) }))
}
return [{ index: "0", values: { Value: data } }]
}
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
if (value !== null && typeof value === "object" && !Array.isArray(value) && !Values.isValue(value)) {
const source = value as Record<string, unknown>
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
return Object.fromEntries(Object.entries(source))
if (value instanceof ProgramObject && !(value instanceof ProgramArray)) {
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, get(value, column)]))
return Object.fromEntries(ownEntries(value))
}
return { Value: value }
}
+28 -42
View File
@@ -3,7 +3,8 @@ import { HostFunction, HostNamespace } from "../interpreter/host.js"
import { applyCollectionCallback, type Runner } from "../interpreter/runner.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { typeofValue } from "../interpreter/references.js"
import { fromData, type SafeObject, toData, toProgram } from "../data.js"
import { fromData, toData, toProgram } from "../data.js"
import { get, ownKeys, ProgramArray, ProgramObject, record, remove, set } from "../interpreter/objects.js"
import { Values } from "../values.js"
export const jsonGlobal = <R>(runner: Runner<R>) =>
@@ -29,28 +30,19 @@ const parse = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effec
if (typeofValue(args[1]) !== "function") return Effect.succeed(parsed)
const apply = applyCollectionCallback(runner, args[1], "JSON.parse", node)
const root: SafeObject = Object.create(null) as SafeObject
root[""] = parsed
const visit = (holder: SafeObject | Array<unknown>, key: string): Effect.Effect<unknown, unknown, R> =>
const visit = (holder: ProgramObject, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = holder[key as keyof typeof holder]
if (Array.isArray(value)) {
const length = value.length
for (let index = 0; index < length; index += 1) {
const revived = yield* visit(value, String(index))
if (revived === undefined) Reflect.deleteProperty(value, index)
else value[index] = revived
}
} else if (isPlainObject(value)) {
for (const name of Object.keys(value)) {
const revived = yield* visit(value, name)
if (revived === undefined) Reflect.deleteProperty(value, name)
else value[name] = revived
const value = get(holder, key)
if (value instanceof ProgramObject) {
for (const name of ownKeys(value)) {
const revived = yield* visit(value, name as string)
if (revived === undefined) remove(value, name)
else set(value, name, revived)
}
}
return yield* apply([key, value])
})
return visit(root, "")
return visit(record({ "": parsed }), "")
}
const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
@@ -59,44 +51,41 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): E
const replacer = args[1]
if (typeofValue(replacer) !== "function") {
const properties = Array.isArray(replacer)
? replacer
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
.map(String)
: null
const properties =
replacer instanceof ProgramArray
? replacer.items
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
.map(String)
: null
return Effect.succeed(JSON.stringify(toData(args[0], "JSON.stringify value"), properties, indent))
}
// Validate up front; the replacer walk below reads the original value.
toProgram(args[0], "JSON.stringify value")
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node)
const root: SafeObject = Object.create(null) as SafeObject
root[""] = args[0]
const stack = new Set<object>()
const visit = (holder: SafeObject | Array<unknown>, key: string): Effect.Effect<unknown, unknown, R> =>
const visit = (holder: ProgramObject, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = yield* apply([key, toJSONValue(holder[key as keyof typeof holder])])
const value = yield* apply([key, toJSONValue(get(holder, key))])
if (value === undefined || typeofValue(value) === "function") return undefined
toProgram(value, "JSON.stringify replacer result")
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (Array.isArray(value)) {
if (stack.has(value))
throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError")
stack.add(value)
if (!(value instanceof ProgramObject)) return {}
if (stack.has(value))
throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError")
stack.add(value)
if (value instanceof ProgramArray) {
const result: Array<unknown> = []
for (let index = 0; index < value.length; index += 1) {
for (let index = 0; index < value.items.length; index += 1) {
result.push((yield* visit(value, String(index))) ?? null)
}
stack.delete(value)
return result
}
if (!isPlainObject(value)) return {}
if (stack.has(value))
throw new InterpreterRuntimeError("Converting circular structure to JSON.", node).as("TypeError")
stack.add(value)
const result: SafeObject = Object.create(null) as SafeObject
for (const name of Object.keys(value)) {
const result: Record<string, unknown> = Object.create(null)
for (const name of ownKeys(value)) {
if (typeof name !== "string") continue
const item = yield* visit(value, name)
if (item !== undefined) result[name] = item
}
@@ -104,7 +93,7 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): E
return result
})
return Effect.map(visit(root, ""), (value) => JSON.stringify(value, null, indent))
return Effect.map(visit(record({ "": args[0] }), ""), (value) => JSON.stringify(value, null, indent))
}
const toJSONValue = (value: unknown): unknown => {
@@ -114,6 +103,3 @@ const toJSONValue = (value: unknown): unknown => {
if (value instanceof Values.URL) return value.url.href
return value
}
const isPlainObject = (value: unknown): value is SafeObject =>
value !== null && typeof value === "object" && !Values.isValue(value)
+30 -52
View File
@@ -2,11 +2,10 @@ import { Effect } from "effect"
import { toProgram } from "../data.js"
import { HostFunction, sync, syncCall } from "../interpreter/host.js"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import { getOwn, hasOwn, ownEntries, ownKeys, ProgramArray, ProgramObject, set } from "../interpreter/objects.js"
import {
containsOpaqueReference,
describeValue,
isRuntimeReference,
parseArrayIndex,
rejectCircularInsertion,
typeofValue,
} from "../interpreter/references.js"
@@ -16,9 +15,8 @@ import { Values } from "../values.js"
import { groupBy } from "./collections.js"
import { coerceToString } from "./value.js"
// ToObject for enumeration. Strings return themselves: the host's Object.keys/entries/hasOwn index a
// primitive string directly. Numbers, booleans, wrappers, and functions have no own enumerable keys.
export const enumerableSource = (label: string, value: unknown, node: AstNode): Record<string, unknown> => {
// ToObject for enumeration.
export const enumerableSource = (label: string, value: unknown, node: AstNode): ProgramObject => {
if (value === null || value === undefined) {
throw new InterpreterRuntimeError(`${label} cannot convert ${describeValue(value)} to an object.`, node).as(
"TypeError",
@@ -38,58 +36,41 @@ export const enumerableSource = (label: string, value: unknown, node: AstNode):
"InvalidDataValue",
)
}
if (typeof value === "string") return value as unknown as Record<string, unknown>
if (typeof value !== "object" || Values.isValue(value) || isRuntimeReference(value)) return {}
return value as Record<string, unknown>
if (typeof value === "string") return new ProgramArray([...value])
if (value instanceof ProgramObject) return value
return new ProgramObject()
}
export const objectAssign = (args: Array<unknown>, node: AstNode): unknown => {
const target = args[0]
// JS would box a primitive target; wrappers and primitives cannot hold fields here.
if (target === null || typeof target !== "object" || Values.isValue(target) || isRuntimeReference(target)) {
if (!(target instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
`Object.assign expects a data object or array target, received ${describeValue(target)}.`,
node,
).as("TypeError")
}
const out = target as Record<string, unknown>
const seen = new Set<object>()
const guardedSet = (key: PropertyKey, item: unknown): void => {
// Arrays hold only indexed elements, as with direct assignment; Reflect.set would otherwise
// reach Array's length and Object.prototype's __proto__ setter.
if (Array.isArray(out) && (typeof key === "symbol" || parseArrayIndex(key) === undefined)) {
throw new InterpreterRuntimeError(
`Object.assign cannot assign '${String(key)}' to an array: only array indexes may be assigned.`,
node,
).as("TypeError")
}
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
if (!Reflect.set(out, key, item))
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
"TypeError",
)
}
for (const source of args.slice(1)) {
if (source === null || source === undefined) continue
const from = enumerableSource("Object.assign(...)", source, node)
if (typeof from !== "object") {
for (const [key, item] of Object.entries(from)) guardedSet(key, item)
continue
}
for (const key of Reflect.ownKeys(from)) {
for (const key of ownKeys(from)) {
if (typeof key === "symbol" && key !== AsyncIteratorSymbol && key !== IteratorSymbol) continue
if (Object.prototype.propertyIsEnumerable.call(from, key)) guardedSet(key, Reflect.get(from, key))
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", node, seen)
if (!set(target, key, getOwn(from, key))) {
throw new InterpreterRuntimeError("Invalid array length", node).as("RangeError")
}
}
}
return out
return target
}
const objectFromEntries = <R>(
runner: Runner<R>,
source: unknown,
node: AstNode,
): Effect.Effect<Record<string, unknown>, unknown, R> => {
const out: Record<string, unknown> = Object.create(null)
): Effect.Effect<ProgramObject, unknown, R> => {
const out = new ProgramObject()
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
if (cursor === undefined) {
@@ -103,21 +84,12 @@ const objectFromEntries = <R>(
yield* preserveConsumerError(
cursor,
Effect.sync(() => {
if (
step.value === null ||
typeof step.value !== "object" ||
Values.isValue(step.value) ||
containsOpaqueReference(step.value)
) {
if (!(step.value instanceof ProgramObject) || containsOpaqueReference(step.value)) {
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node).as(
"TypeError",
)
}
const entry = step.value as Record<string, unknown>
toProgram(entry[0], "Object.fromEntries key")
toProgram(entry[1], "Object.fromEntries value")
const key = coerceToString(entry[0])
out[key] = entry[1]
set(out, coerceToString(getOwn(step.value, 0)), getOwn(step.value, 1))
}),
)
}
@@ -126,7 +98,7 @@ const objectFromEntries = <R>(
const constructObject = (args: Array<unknown>, node: AstNode): unknown => {
const first = args[0]
if (first === null || first === undefined) return Object.create(null)
if (first === null || first === undefined) return new ProgramObject()
if (typeof first === "object") return first
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
@@ -147,18 +119,24 @@ export const objectGlobal = <R>(runner: Runner<R>, toolKeys: (path: ReadonlyArra
toProgram(
args[0] instanceof ToolReference
? [...toolKeys(args[0].path)]
: Object.keys(enumerableSource("Object.keys(...)", args[0], node)),
: ownKeys(enumerableSource("Object.keys(...)", args[0], node)).filter((key) => typeof key === "string"),
"Object.keys result",
),
),
values: sync("Object.values", (args, node) =>
Object.values(enumerableSource("Object.values(...)", args[0], node)),
values: sync(
"Object.values",
(args, node) =>
new ProgramArray(ownEntries(enumerableSource("Object.values(...)", args[0], node)).map((entry) => entry[1])),
),
entries: sync("Object.entries", (args, node) =>
Object.entries(enumerableSource("Object.entries(...)", args[0], node)).map(([key, item]) => [key, item]),
entries: sync(
"Object.entries",
(args, node) =>
new ProgramArray(
ownEntries(enumerableSource("Object.entries(...)", args[0], node)).map((entry) => new ProgramArray(entry)),
),
),
hasOwn: sync("Object.hasOwn", (args, node) =>
Object.hasOwn(
hasOwn(
enumerableSource("Object.hasOwn(...)", args[0], node),
args[1] === AsyncIteratorSymbol || args[1] === IteratorSymbol ? args[1] : String(args[1]),
),
+18 -33
View File
@@ -1,19 +1,9 @@
import { sync, syncCall } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import type { SafeObject } from "../data.js"
import { ProgramArray, record, set } from "../interpreter/objects.js"
import { Values } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
type MatchValue = Array<unknown> & {
index?: number
groups?: SafeObject
indices?: IndicesValue
}
type IndicesValue = Array<unknown> & {
groups?: SafeObject
}
export const regexpMethods = new Set(["test", "exec", "toString"])
export const regexpProperties = new Set([
@@ -56,17 +46,12 @@ export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFl
)
}
export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
const result: MatchValue = Array.from(match, (group) => group)
if (match.index !== undefined) result.index = match.index
if (match.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(match.groups)) {
groups[key] = group
}
result.groups = groups
}
if (match.indices) result.indices = indicesToValue(match.indices)
export const matchToValue = (match: RegExpMatchArray): ProgramArray => {
const result = new ProgramArray(Array.from(match, (group) => group))
if (match.index !== undefined) set(result, "index", match.index)
if (match.input !== undefined) set(result, "input", match.input)
if (match.groups) set(result, "groups", record(match.groups))
if (match.indices) set(result, "indices", indicesToValue(match.indices))
return result
}
@@ -143,16 +128,16 @@ const toLength = (value: unknown): number => {
return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER)
}
const indicesToValue = (indices: RegExpIndicesArray): IndicesValue => {
const result: IndicesValue = Array.from(indices, (range) => (range === undefined ? undefined : [...range]))
if (indices.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, range] of Object.entries(indices.groups)) {
groups[key] = range === undefined ? undefined : [...range]
}
result.groups = groups
return result
}
result.groups = undefined
const indicesToValue = (indices: RegExpIndicesArray): ProgramArray => {
const range = (pair: [number, number] | undefined) => (pair === undefined ? undefined : new ProgramArray([...pair]))
const result = new ProgramArray(Array.from(indices, range))
const groups = indices.groups
set(
result,
"groups",
groups === undefined
? undefined
: record(Object.fromEntries(Object.entries(groups).map(([key, pair]) => [key, range(pair)]))),
)
return result
}
+3 -3
View File
@@ -2,6 +2,7 @@ import { Effect } from "effect"
import { toProgram } from "../data.js"
import { HostFunction, requiresNew, sync, syncCall } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { ownEntries, ProgramObject } from "../interpreter/objects.js"
import { isRuntimeReference } from "../interpreter/references.js"
import { preserveConsumerError, type Runner } from "../interpreter/runner.js"
import { Values } from "../values.js"
@@ -183,15 +184,14 @@ const constructURLSearchParams = <R>(
).as("TypeError")
}
if (Values.isValue(init)) return new Values.URLSearchParams(new URLSearchParams())
const data = toProgram(init, "new URLSearchParams input")
if (data === null || typeof data !== "object") {
if (!(init instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.",
node,
).as("TypeError")
}
return new Values.URLSearchParams(
new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))),
new URLSearchParams(Object.fromEntries(ownEntries(init).map(([key, value]) => [key, coerceToString(value)]))),
)
})
}
+24 -27
View File
@@ -1,6 +1,7 @@
import { type HostFunction, sync, type SyncOptions } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { type SafeObject, toProgram } from "../data.js"
import { toProgram } from "../data.js"
import { get, ProgramArray, ProgramError, set } from "../interpreter/objects.js"
import { Values } from "../values.js"
export const errorConstructors = new Set([
@@ -16,21 +17,21 @@ export const errorConstructors = new Set([
export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
const ErrorBrand: unique symbol = Symbol("codemode.error")
export const createErrorValue = (name: string, message: string): SafeObject => {
const value = Object.assign(Object.create(null) as SafeObject, { name, message })
Object.defineProperty(value, ErrorBrand, { value: name })
export const createErrorValue = (name: string, message: string): ProgramError => {
const value = new ProgramError(name)
set(value, "name", name)
set(value, "message", message)
return value
}
export const createAggregateErrorValue = (errors: Array<unknown>, message: string): SafeObject =>
Object.assign(createErrorValue("AggregateError", message), { errors })
export const createAggregateErrorValue = (errors: Array<unknown>, message: string): ProgramError => {
const value = createErrorValue("AggregateError", message)
set(value, "errors", new ProgramArray(errors))
return value
}
export const errorBrandName = (value: unknown): string | undefined =>
value !== null && typeof value === "object"
? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
: undefined
value instanceof ProgramError ? value.errorName : undefined
export const coerceToString = (value: unknown): string => {
if (value === null) return "null"
@@ -42,29 +43,27 @@ export const coerceToString = (value: unknown): string => {
if (value instanceof Values.Set) return "[object Set]"
if (value instanceof Values.URL) return value.url.href
if (value instanceof Values.URLSearchParams) return value.params.toString()
if (errorBrandName(value) !== undefined) {
if (value instanceof ProgramError) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
const error = value as { name?: unknown; message?: unknown }
const name = typeof error.name === "string" ? error.name : "Error"
const message = typeof error.message === "string" ? error.message : ""
if (message === "") return name
if (name === "") return message
return `${name}: ${message}`
const name = get(value, "name")
const message = get(value, "message")
const shownName = typeof name === "string" ? name : "Error"
const shownMessage = typeof message === "string" ? message : ""
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
}
if (typeof value === "object") {
return Array.isArray(value)
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
: "[object Object]"
if (value instanceof ProgramArray) {
return value.items.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
}
if (typeof value === "object") return "[object Object]"
return String(value)
}
export const coerceToNumber = (value: unknown): number => {
if (value instanceof Values.Date) return value.time
if (Values.isValue(value)) return Number.NaN
// Arrays coerce through our own string coercion: host Number(array) joins with host
// ToPrimitive, which throws on the null-prototype objects the interpreter produces.
if (Array.isArray(value)) return Number(coerceToString(value))
if (value instanceof ProgramArray) return Number(coerceToString(value))
return value !== null && typeof value === "object" ? Number.NaN : Number(value)
}
@@ -78,8 +77,6 @@ const coerce = (name: Coercion, args: Array<unknown>, node: AstNode): unknown =>
if (name === "String") return ""
}
const raw = args[0]
// Error values are plain SafeObjects; the toProgram path below would strip their brand.
if (name === "String" && errorBrandName(raw) !== undefined) return coerceToString(raw)
if (Values.isValue(raw)) {
if (name === "Boolean") return true
if (name === "Number") return coerceToNumber(raw)
+2 -3
View File
@@ -5,9 +5,8 @@ import { coerceToString } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
const base64 = (name: "atob" | "btoa") =>
sync(name, (args, node) => {
if (args.length === 0) {
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
}
if (args.length === 0)
throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node).as("TypeError")
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
+1 -1
View File
@@ -98,7 +98,7 @@ describe("Object.keys over arrays", () => {
)
expect((await error(`const { a } = new Map(); return a`)).message).toContain("received a Map.")
expect((await error(`return Array.from(7)`)).message).toContain("received a number.")
expect((await error(`return (() => 1).x`)).message).toContain("Cannot read properties of a function")
expect(await value(`return [(() => 1).x, Object.keys(() => 1)]`)).toEqual([null, []])
})
})
@@ -167,17 +167,14 @@ describe("Object.assign Test262 parity", () => {
).toEqual([true, [1, 8, 9], true, [1, 8, 3], true, 5, true, 0])
})
test("array targets accept only array indexes (deviation from target-Array.js)", async () => {
test("test/built-ins/Object/assign/target-Array.js", async () => {
expect(
await value(`
const target = [7]
const out = []
for (const source of [{ length: 0 }, { x: 1 }, { "1.5": 1 }, { "-0": 1 }, { ["__proto__"]: null }]) {
try { Object.assign(target, source) } catch (error) { out.push(error.name) }
}
return [out, [...target], target.length, Object.keys(target)]
for (const source of [{ x: 1 }, { "1.5": 1 }, { "-0": 1 }, { 1: 8 }, { length: 1 }]) Object.assign(target, source)
return [[...target], target.length, Object.keys(target), target.x]
`),
).toEqual([["TypeError", "TypeError", "TypeError", "TypeError", "TypeError"], [7], 1, ["0"]])
).toEqual([[7], 1, ["0", "x", "1.5", "-0"], 1])
})
test("test/built-ins/Object/assign/Target-{Null,Undefined}.js", async () => {
+59 -17
View File
@@ -81,27 +81,26 @@ describe("H3: array property access reads as undefined (not a throw)", () => {
).toEqual(["b", "b", null, null, "a", null])
})
test("noncanonical keys cannot mutate or delete an aliased element", async () => {
test("noncanonical keys are ordinary properties that never alias an element", async () => {
expect(
await value(`
const values = ["a", "b"]
let writes = 0
try { values["01"] = ++writes } catch {}
values["01"] = "c"
const before = [values["01"], values[1], values.length]
const removed = delete values["01"]
return [writes, removed, values]
return [before, removed, values["01"], values]
`),
).toEqual([0, true, ["a", "b"]])
).toEqual([["c", "b", 2], true, null, ["a", "b"]])
})
test("the maximum array length is not accepted as an array index", async () => {
test("the maximum array length is a property, not an index", async () => {
expect(
await value(`
const values = []
let writes = 0
try { values["4294967295"] = ++writes } catch {}
return [writes, values.length]
values["4294967295"] = 1
return [values["4294967295"], values.length]
`),
).toEqual([0, 0])
).toEqual([1, 0])
})
})
@@ -246,16 +245,14 @@ describe("property deletion", () => {
expect(await value(`const values = [1, 2]; return [delete values.length, values.length]`)).toEqual([false, 2])
})
test("does not broaden unsupported array property assignment", async () => {
test("arrays accept named properties like JS, and they stay out of the JSON form", async () => {
expect(
await value(`
const values = []
let rightHandSideRuns = 0
function next() { rightHandSideRuns++; return 1 }
try { values.field = next() } catch {}
return rightHandSideRuns
const values = [1]
values.field = 2
return [values.field, Object.keys(values), values]
`),
).toBe(0)
).toEqual([2, ["0", "field"], [1]])
})
test("optional deletion short-circuits without evaluating the key", async () => {
@@ -916,3 +913,48 @@ describe("coercion parity: unknown static members read as undefined", () => {
])
})
})
describe("functions are objects", () => {
test("name follows NamedEvaluation and length counts required parameters", async () => {
expect(
await value(`
function decl(a, b = 1, ...rest) {}
const arrow = () => {}
const named = function inner() {}
let assigned
assigned = (a, b) => {}
const { fromDefault = () => {} } = {}
const [fromArray = function () {}] = []
const obj = { method() {}, key: () => {}, [Symbol.iterator]: () => {} }
const passthrough = (0, () => {})
return [
[decl.name, decl.length],
[arrow.name, named.name, assigned.name, assigned.length],
[fromDefault.name, fromArray.name],
[obj.method.name, obj.key.name, obj[Symbol.iterator].name],
passthrough.name,
]
`),
).toEqual([
["decl", 1],
["arrow", "inner", "assigned", 2],
["fromDefault", "fromArray"],
["method", "key", "[Symbol.iterator]"],
"",
])
})
test("functions hold own properties; name and length are read-only", async () => {
expect(
await value(`
const fn = () => 1
fn.count = 2
fn.count += 1
let renamed = false
try { fn.name = "other" } catch (error) { renamed = error instanceof TypeError }
const { name, count } = fn
return [fn.count, Object.keys(fn), "count" in fn, "name" in fn, name, count, renamed, delete fn.count, fn.count]
`),
).toEqual([3, ["count"], true, true, "fn", 3, true, true, null])
})
})
+8 -106
View File
@@ -17,8 +17,6 @@
import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Tool } from "../src/index.js"
import { AsyncIteratorSymbol, IteratorSymbol } from "../src/interpreter/model.js"
import { objectAssign } from "../src/stdlib/object.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
@@ -770,13 +768,18 @@ describe("stdlib integration", () => {
],
])
expect(await value(`const match = /a/.exec("ba"); return [Object.values(match), Object.entries(match)]`)).toEqual([
["a", 1],
["a", 1, "ba"],
[
["0", "a"],
["index", 1],
["input", "ba"],
],
])
expect(await value(`return Object.keys(Object.values({ match: /a/.exec("ba") })[0])`)).toEqual(["0", "index"])
expect(await value(`return Object.keys(Object.values({ match: /a/.exec("ba") })[0])`)).toEqual([
"0",
"index",
"input",
])
})
test("Object.fromEntries accepts every supported entry collection", async () => {
@@ -849,86 +852,6 @@ describe("stdlib integration", () => {
expect(await value(`try { Object.assign(null, { a: 1 }); return false } catch { return true }`)).toBe(true)
})
test("Object.assign ignores non-enumerable supported symbols without reading them", () => {
const target = {}
const reads: Array<boolean> = []
const source = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(objectAssign([target, source], { type: "CallExpression", start: 0, end: 0 })).toBe(target)
expect(reads).toEqual([])
expect(Object.hasOwn(target, IteratorSymbol)).toBe(false)
})
test("Object.assign ignores nested non-enumerable supported symbols during cycle checks", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperty({}, IteratorSymbol, {
get() {
reads.push(true)
return target
},
})
expect(objectAssign([target, { nested }], { type: "CallExpression", start: 0, end: 0 })).toBe(target)
expect(reads).toEqual([])
expect(target).toEqual({ nested })
})
test("Object.assign rejects cycles through supported symbols on nested arrays", () => {
const target = {}
const nested = Object.defineProperty([], IteratorSymbol, { enumerable: true, value: target })
expect(() => objectAssign([target, { nested }], { type: "CallExpression", start: 0, end: 0 })).toThrow(
"Object.assign result contains a circular value.",
)
expect(Object.hasOwn(target, "nested")).toBe(false)
})
test("Object.assign cycle checks traverse sparse keys lazily", () => {
const target = {}
const reads: Array<boolean> = []
const nested = Object.defineProperties([], {
4294967294: { enumerable: true, value: target },
later: {
enumerable: true,
get() {
reads.push(true)
return null
},
},
})
expect(() => objectAssign([target, { nested }], { type: "CallExpression", start: 0, end: 0 })).toThrow(
"Object.assign result contains a circular value.",
)
expect(reads).toEqual([])
})
test("Object.assign stops after a supported symbol write fails", () => {
const previous = () => ({ done: true })
const target = Object.defineProperty({}, IteratorSymbol, { value: previous })
const reads: Array<boolean> = []
const source = Object.defineProperties(
{},
{
[IteratorSymbol]: { enumerable: true, value: () => ({ done: false }) },
[AsyncIteratorSymbol]: {
enumerable: true,
get() {
reads.push(true)
return () => ({ done: true })
},
},
},
)
expect(() => objectAssign([target, source], { type: "CallExpression", start: 0, end: 0 })).toThrow(
"Object.assign could not assign property",
)
expect(Reflect.get(target, IteratorSymbol)).toBe(previous)
expect(reads).toEqual([])
})
test("Object.assign rejects direct and nested cycles", async () => {
expect(
await value(`
@@ -1000,27 +923,6 @@ describe("stdlib integration", () => {
).toEqual([true, true, true, 2])
})
test("Object.assign traverses shared aliases once", () => {
const reads: Array<boolean> = []
const shared = Object.defineProperty({}, "value", {
enumerable: true,
get() {
reads.push(true)
return 1
},
})
const target = {}
expect(
objectAssign([target, { left: shared, right: shared }], {
type: "CallExpression",
start: 0,
end: 0,
}),
).toBe(target)
expect(target).toEqual({ left: shared, right: shared })
expect(reads).toEqual([true])
})
test("assignment resolves and reads its left side before evaluating the right side", async () => {
expect(await value(`let x = 1; x += (x = 5); return x`)).toBe(6)
expect(await value(`let i = 0; const values = [9]; values[i++] = i; return [values, i]`)).toEqual([[1], 1])
@@ -1199,7 +1101,7 @@ describe("CodeMode values at intra-CodeMode checkpoints", () => {
const diagnostic = await error(`return Array.from(Promise.resolve([1]))`)
expect(diagnostic.kind).toBe("InvalidDataValue")
expect(diagnostic.message).toContain("await")
expect((await error(`return Array.from(() => 1)`)).kind).toBe("InvalidDataValue")
expect(await value(`return Array.from((a, b) => 1)`)).toEqual([null, null])
})
test("regexes stay callable through Object.values", async () => {
+20
View File
@@ -0,0 +1,20 @@
/*
* Runs the test262 files vendored under test/test262 (see test/test262/README.md) verbatim.
* Files listed in test/test262/skipped.txt fail on a known interpreter gap and are skipped;
* `bun run script/test262-report.ts` shows current gaps and which skipped files pass again.
* Licensed under test/LICENSE.test262.
*/
import { test } from "bun:test"
import { root, run, skipped } from "./test262/run.js"
for (const file of [...new Bun.Glob("**/*.js").scanSync({ cwd: root })].sort()) {
const define = skipped.has(file) ? test.skip : test
define(
file,
async () => {
const outcome = await run(file)
if (outcome.status === "fail") throw new Error(outcome.reason)
},
10_000,
)
}
@@ -0,0 +1,3 @@
# Upstream test262 files are populated locally by script/sync-test262.ts and not committed yet.
built-ins/
language/
+41
View File
@@ -0,0 +1,41 @@
# test262
Runs upstream [test262](https://github.com/tc39/test262) files verbatim through `test/test262.test.ts`. The files
themselves are not committed yet: populate them from a local checkout with
```sh
git clone https://github.com/tc39/test262 ~/development/test262
bun run script/sync-test262.ts ~/development/test262
```
Without them the runner registers no tests, so CI is unaffected. Licensed under `test/LICENSE.test262`.
## Layout
- `manifest.json` — the pinned upstream revision, which upstream directories are copied, 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`.
- `run.ts` — runs one file: prepends `"use strict"`, provides the harness (`assert`, `Test262Error`,
`compareArray`, `$DONE`, `$DONOTEVALUATE`) as host globals, and interprets the file's frontmatter (`negative`,
`flags: [async]`).
## What is not vendored
`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 3993 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
of those decisions changes, delete its entry and re-sync; the tests are upstream, not lost.
## Commands
```sh
bun run script/sync-test262.ts /path/to/test262 # re-copy at the pinned revision; edit manifest.json to change scope
bun run script/test262-report.ts [--write] [dir] # run everything, group failures by cause; --write regenerates skipped.txt
bun test test/test262.test.ts # what CI runs
```
When a fix makes skipped files pass, the report lists them so they can be removed from `skipped.txt`, and the
matching gap in `interpreter-support.md` is checked in the same change.

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