Compare commits

..

1 Commits

Author SHA1 Message Date
Kit Langton ffaaad89a4 refactor(core): centralize session message rows 2026-08-11 15:32:51 -04:00
31 changed files with 199 additions and 900 deletions
-1
View File
@@ -1,4 +1,3 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
packages/core/src/models-dev/snapshot.txt linguist-generated
packages/core/src/**/*.txt text eol=lf
+1 -56
View File
@@ -124,66 +124,12 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli-unsigned
name: opencode-preview-cli
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-macos:
needs: build-cli
runs-on: macos-26
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-unsigned
path: packages/cli/dist
- name: Sign macOS CLI binaries
run: |
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
if [ -z "$identity" ]; then
echo "Developer ID Application identity not found"
exit 1
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
found=1
codesign \
--force \
--timestamp \
--options runtime \
--entitlements packages/cli/script/entitlements.plist \
--sign "$identity" \
"$file"
codesign --verify --deep --strict --verbose=4 "$file"
codesign --display --requirements - "$file"
done
if [ "$found" -eq 0 ]; then
echo "No macOS CLI binaries found"
exit 1
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
@@ -522,7 +468,6 @@ jobs:
needs:
- version
- build-cli
- sign-cli-macos
- build-node-cli
- sign-cli-windows
- build-electron
+3 -22
View File
@@ -2,12 +2,13 @@
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { chmod, copyFile, mkdir, mkdtemp, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import { chmod, copyFile, mkdir, mkdtemp, realpath, rename, rm, stat, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { build } from "vite"
import { Script } from "@opencode-ai/script"
import pkg from "../package.json"
import { modelsData } from "./generate"
import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "./node-assets"
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
@@ -55,34 +56,14 @@ const builder =
? await resolveHostNode()
: undefined
// Vite silently rewrites text imports of known asset types (.txt) to asset
// URL strings when the raw-text plugin doesn't intercept them first — the
// bundle still builds and `--help` still runs, so only content assertions
// catch it. Guards the models.dev snapshot and the prompt/tool description
// text that ships inside the bundle.
async function assertTextImportsInlined(bundlePath: string) {
const bundle = await readFile(bundlePath, "utf8")
const markers = [
{ marker: '"zhipuai"', source: "models-dev snapshot" },
{ marker: "/assets/snapshot", source: "models-dev snapshot inlined as asset URL", forbidden: true },
{ marker: '="/assets/', source: "text import inlined as asset URL", forbidden: true },
]
for (const { marker, source, forbidden } of markers) {
const present = bundle.includes(marker)
if (forbidden ? present : !present)
throw new Error(`${bundlePath}: ${source} — text imports are not inlined as content (marker ${marker})`)
}
}
for (const target of targets) {
console.log(`building cli-node-${targetName(target)}`)
const assets = await collectNodeAssets(target)
await rm("dist-node", { recursive: true, force: true })
const assetHash = await hashNodeAssets(assets)
const input = { version: Script.version, channel: Script.channel, assetHash, target }
const input = { version: Script.version, channel: Script.channel, models: modelsData, assetHash, target }
await copyNodeAssets(assets)
await build(mainConfig(input))
await assertTextImportsInlined("dist-node/opencode.mjs")
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
+2
View File
@@ -7,6 +7,7 @@ import { Script } from "@opencode-ai/script"
import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { modelsData } from "./generate"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -98,6 +99,7 @@ for (const item of targets) {
define: {
OPENCODE_VERSION: `'${Script.version}'`,
OPENCODE_CLI_NAME: `'${binary}'`,
OPENCODE_MODELS_DEV: modelsData,
OPENCODE_CHANNEL: `'${Script.channel}'`,
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+9
View File
@@ -0,0 +1,9 @@
import { readFile } from "node:fs/promises"
const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
export const modelsData = process.env.MODELS_DEV_API_JSON
? await readFile(process.env.MODELS_DEV_API_JSON, "utf8")
: await fetch(`${modelsUrl}/api.json`).then((response) => response.text())
console.log("Loaded models.dev snapshot")
+4 -6
View File
@@ -10,13 +10,8 @@ const dir = import.meta.dirname
function rawTextPlugin(): Plugin {
return {
name: "opencode:raw-text",
// "pre" is load-bearing for .txt: Vite's built-in asset plugin claims
// known asset types (.txt among them) ahead of normal-priority plugins,
// replacing the import with an asset URL string instead of the content.
// .md only ever worked without it because .md is not a known asset type.
enforce: "pre",
async load(id) {
if (!id.endsWith(".md") && !id.endsWith(".txt")) return
if (!id.endsWith(".md")) return
return `export default ${JSON.stringify(await readFile(id, "utf8"))}`
},
}
@@ -214,6 +209,7 @@ if (process.platform === "linux") process.env.OPENTUI_LIBC = "glibc"`
export type NodeBuildInput = {
readonly version: string
readonly channel: string
readonly models: string
readonly assetHash: string
readonly target: NodeTarget
}
@@ -237,6 +233,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
define: {
OPENCODE_VERSION: JSON.stringify(input.version),
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
OPENCODE_MODELS_DEV: input.models,
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
@@ -259,6 +256,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
export default mainConfig({
version: process.env.OPENCODE_VERSION ?? "local",
channel: process.env.OPENCODE_CHANNEL ?? "local",
models: "undefined",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
})
-2
View File
@@ -10,7 +10,6 @@
"migration": "bun run script/migration.ts",
"fix-node-pty": "bun run script/fix-node-pty.ts",
"benchmark:location": "bun run script/benchmark-location.ts",
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
"test": "bun test --only-failures",
"typecheck": "tsgo -b tsconfig.json tsconfig.tests.json"
},
@@ -26,7 +25,6 @@
},
"imports": {
"#sqlite": {
"workerd": "./src/database/sqlite.workerd.ts",
"bun": "./src/database/sqlite.bun.ts",
"node": "./src/database/sqlite.node.ts",
"default": "./src/database/sqlite.bun.ts"
@@ -1,24 +0,0 @@
#!/usr/bin/env bun
/**
* Refreshes the bundled models.dev catalog snapshot at src/models-dev/snapshot.txt.
* The snapshot is the boot-time floor for the catalog when no cache entry exists
* and fetching is disabled or unavailable; live fetch still refreshes on top.
*/
const source = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const response = await fetch(`${source}/api.json`)
if (!response.ok) {
console.error(`Failed to fetch ${source}/api.json: ${response.status} ${response.statusText}`)
process.exit(1)
}
const text = await response.text()
const parsed: unknown = JSON.parse(text)
// A floor, not equality: guards against committing an error page or a
// truncated body that still parses as a small object.
const MINIMUM_PROVIDERS = 100
if (typeof parsed !== "object" || parsed === null || Object.keys(parsed).length < MINIMUM_PROVIDERS) {
console.error(`Fetched catalog has fewer than ${MINIMUM_PROVIDERS} providers; refusing to write snapshot`)
process.exit(1)
}
const target = new URL("../src/models-dev/snapshot.txt", import.meta.url)
await Bun.write(target, text)
console.log(`Wrote ${Object.keys(parsed).length} providers (${text.length} bytes) to ${Bun.fileURLToPath(target)}`)
+8 -15
View File
@@ -88,13 +88,9 @@ function mapBedrockSettings(
: typeof settings.bearerToken === "string"
? settings.bearerToken
: undefined
const region = bedrockRegion(settings)
const credentials = mapBedrockCredentials(settings, region)
const credentials = mapBedrockCredentials(settings)
return {
...baseSettings,
...(typeof baseSettings.baseURL === "string" && region !== undefined
? { baseURL: baseSettings.baseURL.replaceAll("${AWS_REGION}", region) }
: {}),
...(typeof settings.baseURL !== "string" && typeof settings.endpoint === "string"
? { baseURL: settings.endpoint }
: {}),
@@ -159,8 +155,14 @@ function mapBedrockRequest(input: MapInput): Pick<Mapping, "headers" | "body"> {
}
}
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, region: string | undefined) {
function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
const region =
typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
if (
region === undefined ||
typeof credentials.accessKeyId !== "string" ||
@@ -175,15 +177,6 @@ function mapBedrockCredentials(settings: Readonly<Record<string, unknown>>, regi
}
}
function bedrockRegion(settings: Readonly<Record<string, unknown>>) {
const credentials = isRecord(settings.credentials) ? settings.credentials : settings
return typeof settings.region === "string"
? settings.region
: typeof credentials.region === "string"
? credentials.region
: undefined
}
function mapOpenAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
+8 -18
View File
@@ -1,9 +1,8 @@
export * as Database from "./database"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
import { sqliteLayer } from "#sqlite"
import { Context, Effect, Layer, Schema } from "effect"
import type { SqlClient } from "effect/unstable/sql"
import { Global } from "@opencode-ai/util/global"
import { isAbsolute, join } from "path"
import { DatabaseMigration } from "./migration"
@@ -28,15 +27,12 @@ const databaseLayer = Layer.effect(
Effect.gen(function* () {
const db = yield* makeDatabase
if (supportsTuningPragmas) {
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
}
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA journal_mode = WAL")
yield* db.run("PRAGMA synchronous = NORMAL")
yield* db.run("PRAGMA busy_timeout = 5000")
yield* db.run("PRAGMA cache_size = -64000")
yield* db.run("PRAGMA foreign_keys = ON")
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
yield* DatabaseMigration.apply(db)
return { db }
@@ -46,7 +42,7 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
@@ -55,12 +51,6 @@ export function layer(options: Options = { path: ":memory:" }) {
)
}
// The database service over an injected SqlClient, for runtimes that receive
// database storage instead of opening a filesystem path. Any client provided
// here still goes through the pragma guards and migrations; Global is required
// because migrations may read it (the v1 import).
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
export function configured(options?: Options) {
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
}
+3 -12
View File
@@ -2,7 +2,6 @@ export * as DatabaseMigration from "./migration"
import { sql } from "drizzle-orm"
import { Effect, Semaphore } from "effect"
import { supportsForeignKeyToggle } from "#sqlite"
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { migrations } from "./migration.gen"
import schema from "./schema.gen"
@@ -21,10 +20,8 @@ export type Migration = {
export function apply(db: Database) {
return lock.withPermit(
Effect.gen(function* () {
// OpenCode owns the unprefixed table namespace. Embedders sharing this
// database may own underscore-prefixed tables, which bootstrap ignores.
const tables = yield* db.all<{ name: string }>(
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`,
)
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
return yield* applyOnly(db, migrations)
@@ -106,15 +103,9 @@ export function applyOnly(db: Database, input: Migration[]) {
})
continue
}
// Durable Object SQLite rejects the foreign_keys toggle; the closest
// allowlisted relaxation is deferring enforcement to transaction commit.
const relaxForeignKeys = supportsForeignKeyToggle
? db.run(sql`PRAGMA foreign_keys = OFF`)
: db.run(sql`PRAGMA defer_foreign_keys = ON`)
const restoreForeignKeys = supportsForeignKeyToggle ? db.run(sql`PRAGMA foreign_keys = ON`) : Effect.void
yield* relaxForeignKeys
yield* db.run(sql`PRAGMA foreign_keys = OFF`)
yield* apply.pipe(
Effect.ensuring(restoreForeignKeys.pipe(Effect.orDie)),
Effect.ensuring(db.run(sql`PRAGMA foreign_keys = ON`).pipe(Effect.orDie)),
Effect.tapError((error) =>
Effect.logError("database migration failed", {
migration: migration.id,
-5
View File
@@ -8,11 +8,6 @@ import { Sqlite } from "./sqlite"
const TypeId = "~@opencode-ai/core/database/SqliteBun" as const
export const supportsTuningPragmas = true
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
readonly filename: string
readonly readonly?: boolean
@@ -8,11 +8,6 @@ import { Sqlite } from "./sqlite"
const TypeId = "~@opencode-ai/core/database/SqliteNode" as const
export const supportsTuningPragmas = true
// Foreign keys default OFF and can be toggled per connection.
export const supportsForeignKeyToggle = true
interface Config extends Sqlite.ClientConfig {
readonly filename: string
readonly readonly?: boolean
@@ -1,254 +0,0 @@
import { drizzle } from "drizzle-orm/durable-sqlite"
import { Context, Effect, Exit, Fiber, Layer, Scope, Semaphore, Stream } from "effect"
import { identity } from "effect/Function"
import { Reactivity } from "effect/unstable/reactivity"
import { SqlClient, Statement } from "effect/unstable/sql"
import type { Connection } from "effect/unstable/sql/SqlConnection"
import { classifySqliteError, SqlError, UnknownError } from "effect/unstable/sql/SqlError"
import { Sqlite } from "./sqlite"
const ATTR_DB_SYSTEM_NAME = "db.system.name"
const TypeId = "~@opencode-ai/core/database/SqliteWorkerd" as const
type TypeId = typeof TypeId
// Durable Object SQLite only allowlists introspection pragmas; journal_mode,
// synchronous, busy_timeout, cache_size, and wal_checkpoint all throw, and
// foreign keys are already enforced by default (SQLITE_DEFAULT_FOREIGN_KEYS=1).
export const supportsTuningPragmas = false
// Durable Object SQLite rejects `PRAGMA foreign_keys`: enforcement is always
// on (SQLITE_DEFAULT_FOREIGN_KEYS=1) and only `defer_foreign_keys` is
// allowlisted for migrations that must relax checking inside a transaction.
export const supportsForeignKeyToggle = false
// Minimal structural types for the Durable Object storage API so this adapter
// does not depend on @cloudflare/workers-types (whose ambient globals conflict
// with @types/bun). Shapes match the SqlStorage and DurableObjectStorage docs.
type SqlStorageValue = ArrayBuffer | string | number | null
interface SqlStorageCursor {
readonly columnNames: Array<string>
raw(): IterableIterator<Array<SqlStorageValue>>
toArray(): Array<Record<string, SqlStorageValue>>
}
export interface SqlStorage {
exec(query: string, ...bindings: Array<unknown>): SqlStorageCursor
}
export interface DurableObjectStorage {
readonly sql: SqlStorage
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T>
transactionSync<T>(closure: () => T): T
}
interface SqliteClient extends SqlClient.SqlClient {
readonly [TypeId]: TypeId
readonly config: Config
readonly updateValues: never
}
interface Config {
readonly storage: DurableObjectStorage
readonly spanAttributes?: Record<string, unknown>
readonly transformResultNames?: (str: string) => string
readonly transformQueryNames?: (str: string) => string
}
// sql.exec() rejects BEGIN/COMMIT/SAVEPOINT, so SqlClient.make's default
// transaction SQL can never run. withTransaction is replaced below with a
// DurableObjectStorage.transaction-backed implementation; this service only
// tracks the active transaction connection for statements and nesting checks.
const WorkerdTransaction = Context.Service<SqlClient.TransactionConnection, SqlClient.TransactionConnection.Service>(
"@opencode-ai/core/database/SqliteWorkerdTransaction",
)
const transactionError = (message: string) =>
new SqlError({
reason: new UnknownError({ cause: new Error(message), message, operation: "transaction" }),
})
const makeWithTransaction =
(
storage: DurableObjectStorage,
connection: Connection,
semaphore: Semaphore.Semaphore,
): SqlClient.SqlClient["withTransaction"] =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E | SqlError, R> =>
Effect.withFiber((fiber) => {
const services = fiber.context
if (Context.getOption(services, WorkerdTransaction)._tag === "Some")
return Effect.fail(
transactionError("Nested transactions are not supported by Cloudflare Durable Object SQLite storage"),
)
const effectWithTxn = Effect.provideContext(
effect,
Context.add(services, WorkerdTransaction, [connection, 0] as const),
)
return semaphore.withPermits(1)(
Effect.callback((resume) => {
let interrupted = false
const promise = storage
.transaction(
(txn) =>
new Promise<void>((resolve) => {
if (interrupted) return resolve()
resume(
Effect.onExit(effectWithTxn, (exit) => {
if (Exit.isFailure(exit)) txn.rollback()
resolve()
// wait for the transaction to complete
return Effect.promise(() => promise)
}),
)
}),
)
.catch((cause) =>
resume(
Effect.fail(
new SqlError({
reason: classifySqliteError(cause, { message: "Failed transaction", operation: "transaction" }),
}),
),
),
)
return Effect.suspend(() => {
interrupted = true
return Effect.promise(() => promise)
})
}),
)
})
const make = (options: Config) =>
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
const compiler = Statement.makeCompilerSqlite(options.transformQueryNames)
const transformRows = options.transformResultNames
? Statement.defaultTransforms(options.transformResultNames).array
: undefined
// SqlClient.SafeIntegers is ignored: Durable Object SQLite has no bigint
// mode and always returns integers as numbers. Blobs come back as
// ArrayBuffer and are normalized to Uint8Array to match the other adapters.
function* runIterator(query: string, params: ReadonlyArray<unknown> = []) {
const cursor = native.sql.exec(query, ...params)
const columns = cursor.columnNames
for (const row of cursor.raw()) {
const record: Record<string, unknown> = {}
for (let i = 0; i < columns.length; i++) {
const value = row[i]
record[columns[i]] = value instanceof ArrayBuffer ? new Uint8Array(value) : value
}
yield record
}
}
const run = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.try({
try: () => Array.from(runIterator(query, params)),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
})
const runValues = (query: string, params: ReadonlyArray<unknown> = []) =>
Effect.try({
try: () =>
Array.from(native.sql.exec(query, ...params).raw(), (row) =>
row.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value)),
),
catch: (cause) =>
new SqlError({
reason: classifySqliteError(cause, { message: "Failed to execute statement", operation: "execute" }),
}),
})
const connection = identity<Connection>({
execute(query, params, transformRows) {
return transformRows ? Effect.map(run(query, params), transformRows) : run(query, params)
},
executeRaw(query, params) {
return run(query, params)
},
executeValues(query, params) {
return runValues(query, params)
},
executeValuesUnprepared(query, params) {
return runValues(query, params)
},
executeUnprepared(query, params, transformRows) {
return this.execute(query, params, transformRows)
},
executeStream() {
return Stream.die("executeStream not implemented")
},
})
const semaphore = yield* Semaphore.make(1)
const acquirer = semaphore.withPermits(1)(Effect.succeed(connection))
const transactionAcquirer = Effect.uninterruptibleMask((restore) => {
const fiber = Fiber.getCurrent()!
const scope = Context.getUnsafe(fiber.context, Scope.Scope)
return Effect.as(
Effect.tap(restore(semaphore.take(1)), () => Scope.addFinalizer(scope, semaphore.release(1))),
connection,
)
})
const client = Object.assign(
(yield* SqlClient.make({
acquirer,
compiler,
transactionAcquirer,
transactionService: WorkerdTransaction,
spanAttributes: [
...(options.spanAttributes ? Object.entries(options.spanAttributes) : []),
[ATTR_DB_SYSTEM_NAME, "sqlite"],
],
transformRows,
})) as SqliteClient,
{
[TypeId]: TypeId,
config: options,
withTransaction: makeWithTransaction(native, connection, semaphore),
// Durable Object SQLite rejects BEGIN/COMMIT/SAVEPOINT; consumers such
// as the drizzle session must route through withTransaction instead.
transactionStatements: false,
},
)
return client
})
// Defends against the shared path-based Database.layer, which passes a
// filename instead of storage when resolved under the workerd condition.
const nativeLayer = (config: Config) =>
config.storage
? Layer.succeed(Sqlite.Native, config.storage)
: Layer.effect(
Sqlite.Native,
Effect.die(
"workerd sqlite cannot open a database from a path; use Database.layerWith(sqliteLayer({ storage }))",
),
)
const clientLayer = (config: Config) => Layer.effect(SqlClient.SqlClient, make(config))
const drizzleLayer = Layer.effect(
Sqlite.Drizzle,
Effect.gen(function* () {
const native = (yield* Sqlite.Native) as DurableObjectStorage
return drizzle(native) as unknown as Sqlite.DrizzleClient
}),
)
export const sqliteLayer = (config: Config) => {
const native = nativeLayer(config)
return Layer.merge(native, Layer.merge(clientLayer(config), drizzleLayer).pipe(Layer.provide(native))).pipe(
Layer.provide(Reactivity.layer),
)
}
+70 -110
View File
@@ -158,42 +158,45 @@ export const fromCatalogModel = (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
)
const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(function* (
model: Info,
credential?: Credential.Value,
dependencies?: Dependencies,
) {
const resolved = prepareRuntimeModel(model, credential)
): Effect.Effect<LanguageModel, UnsupportedPackageError | UnresolvedProviderVariablesError> => {
const prepared = prepareRuntimeModel(model, credential)
if (prepared.unresolved.length > 0)
return Effect.fail(
new UnresolvedProviderVariablesError({
providerID: model.providerID,
modelID: model.id,
variables: prepared.unresolved,
}),
)
const resolved = prepared.model
const packageName = Provider.packageName(resolved.package)
const key = apiKey(resolved, credential)
const configuration = credential?.type === "key" ? credential.configuration : undefined
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/openai") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
return Effect.succeed(
withDefaults(resolved, OpenAIResponses.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (Provider.isAISDK(resolved.package) && packageName === "@ai-sdk/anthropic") {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
return Effect.succeed(
withDefaults(resolved, AnthropicMessages.route)
.with({ auth: key === undefined ? Auth.none : Auth.header("x-api-key", key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
if (
Provider.isAISDK(resolved.package) &&
packageName === "@ai-sdk/openai-compatible" &&
typeof resolved.settings?.baseURL === "string"
) {
const runtime = yield* prepareProviderModel(resolved)
return withDefaults(runtime, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: runtime.modelID ?? runtime.id, compatibility: runtime.compatibility })
return Effect.succeed(
withDefaults(resolved, OpenAICompatibleChat.route)
.with({ auth: key === undefined ? Auth.none : Auth.bearer(key) })
.model({ id: resolved.modelID ?? resolved.id, compatibility: resolved.compatibility }),
)
}
const configured = { ...resolved.settings, ...credential?.metadata, ...configuration }
const mapping = Provider.isAISDK(resolved.package)
@@ -205,107 +208,64 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
: undefined
const native = mapping?.package ?? resolved.package
if (Provider.isAISDK(resolved.package) && !mapping) {
const loadAISDK = dependencies?.loadAISDK
if (!loadAISDK) return yield* unsupported(resolved)
const settings = yield* prepareProviderSettings(
resolved,
Provider.mergeOverlay(resolved.settings, {
if (!dependencies?.loadAISDK) return Effect.fail(unsupported(resolved))
const runtime = produce(resolved, (draft) => {
draft.settings = Provider.mergeOverlay(draft.settings, {
...nativeCredentialSettings(resolved.package ?? "", credential),
...credential?.metadata,
...configuration,
}) ?? {},
)
const runtime = produce(resolved, (draft) => {
draft.settings = settings
})
})
return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
return dependencies.loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!native) return yield* unsupported(resolved)
if (!native) return Effect.fail(unsupported(resolved))
const specifier = native
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
return Effect.gen(function* () {
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError(() => unsupported(resolved)),
)
const mapped = mapping?.settings ?? configured
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
const runtime = module.model(resolved.modelID ?? resolved.id, settings)
return LanguageModel.update(runtime, {
provider: resolved.providerID,
compatibility: resolved.compatibility
? Object.assign({}, runtime.compatibility, resolved.compatibility)
: runtime.compatibility,
})
},
catch: () => unsupported(resolved),
})
})
})
}
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
return produce(model, (draft) => {
const prepared = produce(model, (draft) => {
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
if (credential?.type === "key" && credential.metadata !== undefined)
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
if (typeof draft.settings?.baseURL !== "string") return
draft.settings.baseURL = draft.settings.baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => {
return process.env[name] ?? placeholder
})
})
}
function validateProviderVariables(
model: Info,
resolved: LanguageModel,
): Effect.Effect<LanguageModel, UnresolvedProviderVariablesError> {
const baseURL = resolved.route.endpoint.baseURL
if (typeof baseURL !== "string") return Effect.succeed(resolved)
const failure = unresolvedProviderVariables(model, baseURL)
return failure ? Effect.fail(failure) : Effect.succeed(resolved)
}
function prepareProviderModel(model: Info): Effect.Effect<Info, UnresolvedProviderVariablesError> {
if (!model.settings) return Effect.succeed(model)
return prepareProviderSettings(model, model.settings).pipe(
Effect.map((settings) =>
settings === model.settings
? model
: produce(model, (draft) => {
draft.settings = settings
}),
),
)
}
function prepareProviderSettings(
model: Info,
settings: Readonly<Record<string, unknown>>,
): Effect.Effect<Readonly<Record<string, unknown>>, UnresolvedProviderVariablesError> {
const baseURL = settings.baseURL
if (typeof baseURL !== "string") return Effect.succeed(settings)
return prepareProviderURL(model, baseURL).pipe(
Effect.map((prepared) => (prepared === baseURL ? settings : { ...settings, baseURL: prepared })),
)
}
function prepareProviderURL(model: Info, baseURL: string): Effect.Effect<string, UnresolvedProviderVariablesError> {
if (!baseURL.includes("${")) return Effect.succeed(baseURL)
const prepared = baseURL.replace(/\$\{([^}]+)\}/g, (placeholder, name: string) => process.env[name] ?? placeholder)
const failure = unresolvedProviderVariables(model, prepared)
return failure ? Effect.fail(failure) : Effect.succeed(prepared)
}
function unresolvedProviderVariables(model: Info, baseURL: string) {
const variables = new Set(Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]))
if (variables.size === 0) return
return new UnresolvedProviderVariablesError({
providerID: model.providerID,
modelID: model.id,
variables: Array.from(variables),
})
const baseURL = prepared.settings?.baseURL
const unresolved =
typeof baseURL === "string"
? Array.from(baseURL.matchAll(/\$\{([^}]+)\}/g), (match) => match[1]).filter(
(name, index, names) => names.indexOf(name) === index,
)
: []
return { model: prepared, unresolved }
}
const nativeCredentialSettings = (specifier: string, credential: Credential.Value | undefined) => {
+6 -12
View File
@@ -11,7 +11,6 @@ import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
import { Model } from "./model"
import { Provider } from "./provider"
import { KV } from "./kv"
import snapshotText from "./models-dev/snapshot.txt" with { type: "text" }
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -520,6 +519,8 @@ function modelInfo(
export { Event } from "@opencode-ai/schema/models-dev"
declare const OPENCODE_MODELS_DEV: Record<string, SourceProvider> | undefined
export interface Interface {
readonly get: () => Effect.Effect<readonly Snapshot[]>
readonly refresh: (force?: boolean) => Effect.Effect<void>
@@ -529,17 +530,12 @@ export const Options = Schema.Struct({
url: Schema.optional(Schema.String),
file: Schema.optional(Schema.String),
fetch: Schema.optional(Schema.Boolean),
snapshot: Schema.optional(Schema.Boolean),
})
export type Options = typeof Options.Type
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const CatalogJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown))
const decodeCatalog = (text: string) =>
Schema.decodeUnknownEffect(CatalogJson)(text).pipe(
Effect.map((catalog) => catalog as Record<string, SourceProvider>),
)
const Cache = Schema.Struct({
updatedAt: Schema.Number,
body: CatalogJson,
@@ -609,15 +605,13 @@ export const layer = (options?: Options) =>
)
: Effect.succeed(undefined)
// Bundled snapshot of https://models.opencode.ai/api.json, committed at
// packages/core/src/models-dev/snapshot.txt and refreshed via
// `bun run script/update-models-snapshot.ts`. It is the boot-time floor
// for the catalog; the periodic fetch below still refreshes on top.
const loadSnapshot = options?.snapshot === false ? Effect.succeed(undefined) : decodeCatalog(snapshotText)
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
)
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = yield* decodeCatalog(text)
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
File diff suppressed because one or more lines are too long
+3 -4
View File
@@ -1,8 +1,9 @@
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
import { Effect, Schema } from "effect"
import { Effect } from "effect"
import { Database } from "../database/database"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionSchema } from "./schema"
import { Instructions } from "../instructions/index"
import { InstructionState } from "./instruction-state"
@@ -10,8 +11,6 @@ import { SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
return yield* db
.select({ seq: SessionMessageTable.seq })
@@ -50,7 +49,7 @@ const messageRows = Effect.fnUntraced(function* (
})
const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
SessionMessageRow.decode(row).pipe(
Effect.mapError(
() =>
new MessageDecodeError({
+20
View File
@@ -0,0 +1,20 @@
export * as SessionMessageRow from "./message-row"
import { Schema } from "effect"
import { SessionMessage } from "./message"
import type { SessionMessageTable } from "./sql"
export type Representation = Pick<typeof SessionMessageTable.$inferSelect, "id" | "type" | "data">
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const decodeMessageSync = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
export const decode = (row: Representation) => decodeMessage({ ...row.data, id: row.id, type: row.type })
export const decodeSync = (row: Representation) => decodeMessageSync({ ...row.data, id: row.id, type: row.type })
export function encode(message: SessionMessage.Info): Representation {
const { id, type, ...data } = encodeMessage(message)
return { id: SessionMessage.ID.make(id), type, data }
}
+2 -2
View File
@@ -17,6 +17,7 @@ import { Bus } from "../bus"
import { KeyedMutex } from "../effect/keyed-mutex"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionSchema } from "./schema"
import { SessionMessageTable, SessionPendingTable } from "./sql"
@@ -35,7 +36,6 @@ const decodeUser = Schema.decodeUnknownSync(UserData)
const encodeUser = Schema.encodeSync(UserData)
const decodeSynthetic = Schema.decodeUnknownSync(SyntheticData)
const encodeSynthetic = Schema.encodeSync(SyntheticData)
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const inboxLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
type PendingRef = { readonly id: SessionMessage.ID; readonly sessionID: SessionSchema.ID }
@@ -113,7 +113,7 @@ const promotedFromMessage = Effect.fn("SessionPending.promotedFromMessage")(func
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* Effect.die(new LifecycleConflict({ id }))
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const message = SessionMessageRow.decodeSync(row)
const base = { id, sessionID, timeCreated: message.time.created, delivery }
if (message.type === "user")
return User.make({
+9 -19
View File
@@ -9,6 +9,7 @@ import { Agent } from "../agent"
import { Model } from "../model"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionMessageUpdater } from "./message-updater"
import { SessionPending } from "./pending"
import { Workspace } from "../workspace"
@@ -22,9 +23,6 @@ type DatabaseService = Database.Interface["db"]
type CurrentDurableEvent = Extract<SessionEvent.Event, { readonly durable: object }>
type MessageEvent = Exclude<CurrentDurableEvent, typeof SessionEvent.Forked.Type | typeof SessionEvent.Deleted.Type>
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Info)
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
export class SessionAlreadyProjected extends Error {}
type Usage = {
@@ -210,22 +208,15 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) => SessionMessageRow.decodeSync(row)
const updateMessage = (message: SessionMessage.Info) => {
if (event.durable === undefined)
return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
const row = SessionMessageRow.encode(message)
return db
.update(SessionMessageTable)
.set({ type, time_created: DateTime.toEpochMillis(message.time.created), data })
.where(
and(
eq(SessionMessageTable.id, SessionMessage.ID.make(id)),
eq(SessionMessageTable.session_id, event.data.sessionID),
),
)
.set({ type: row.type, time_created: DateTime.toEpochMillis(message.time.created), data: row.data })
.where(and(eq(SessionMessageTable.id, row.id), eq(SessionMessageTable.session_id, event.data.sessionID)))
.run()
.pipe(Effect.orDie)
}
@@ -343,17 +334,16 @@ function run(db: DatabaseService, event: MessageEvent) {
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
const row = SessionMessageRow.encode(message)
return db
.insert(SessionMessageTable)
.values({
id: SessionMessage.ID.make(id),
id: row.id,
session_id: event.data.sessionID,
type,
type: row.type,
seq: event.durable.seq,
time_created: DateTime.toEpochMillis(message.time.created),
data,
data: row.data,
})
.run()
.pipe(Effect.orDie)
+2 -2
View File
@@ -8,6 +8,7 @@ import { RelativePath } from "../schema"
import { Snapshot } from "../snapshot"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionSchema } from "./schema"
import { SessionMessageTable } from "./sql"
@@ -46,10 +47,9 @@ const plan = Effect.fn("SessionRevert.plan")(function* (input: BoundaryInput) {
.orderBy(asc(SessionMessageTable.seq))
.all()
.pipe(Effect.orDie)
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
const message = yield* SessionMessageRow.decode(row).pipe(Effect.orDie)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
+3 -4
View File
@@ -1,12 +1,13 @@
export * as SessionStore from "./store"
import { and, eq, isNotNull, isNull, sql } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, Effect, Layer } from "effect"
import { Database } from "../database/database"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionHistory } from "./history"
import { MessageDecodeError } from "./error"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { Session } from "@opencode-ai/schema/session"
import { SessionMessageTable, SessionTable } from "./sql"
import { fromRow } from "./info"
@@ -51,8 +52,6 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const { db } = yield* Database.Service
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
return Service.of({
get: Effect.fn("SessionStore.get")(function* (sessionID) {
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get().pipe(Effect.orDie)
@@ -71,7 +70,7 @@ const layer = Layer.effect(
return row
? {
sessionID: Session.ID.make(row.session_id),
message: yield* decodeMessage({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie),
message: yield* SessionMessageRow.decode(row).pipe(Effect.orDie),
}
: undefined
}),
+4 -6
View File
@@ -18,6 +18,7 @@ import { Session } from "../session"
import { Slug } from "../util/slug"
import { SessionEvent } from "./event"
import { SessionMessage } from "./message"
import { SessionMessageRow } from "./message-row"
import { SessionProjector } from "./projector"
import { SessionMessageTable, SessionTable } from "./sql"
@@ -47,8 +48,6 @@ const layer = Layer.effect(
const { db } = yield* Database.Service
const projects = yield* Project.Service
const sessions = yield* Session.Service
const encodeMessage = Schema.encodeSync(SessionMessage.Info)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
return Service.of({
@@ -71,15 +70,14 @@ const layer = Layer.effect(
const project = yield* projects.resolve(input.location.directory)
yield* persistProject(project)
const messages = input.data.messages.map((message, index) => {
const encoded = encodeMessage(message)
const { id: _, type, ...data } = encoded
const row = SessionMessageRow.encode(message)
return {
id: message.id,
session_id: sessionID,
type,
type: row.type,
seq: index + 1,
time_created: DateTime.toEpochMillis(message.time.created),
data,
data: row.data,
}
})
yield* bus
+2 -13
View File
@@ -123,16 +123,6 @@ describe("AISDKNative", () => {
expect(map("@ai-sdk/amazon-bedrock/mantle", settings, "openai.gpt-oss-safeguard-20b")?.package).toBe(
"@opencode-ai/ai/providers/amazon-bedrock/mantle/chat",
)
expect(
map(
"@ai-sdk/amazon-bedrock/mantle",
{
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
"openai.gpt-5.5",
),
).toMatchObject({ settings: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" } })
})
test("maps static Bedrock Mantle credentials without leaking connection options", () => {
@@ -144,9 +134,8 @@ describe("AISDKNative", () => {
accessKeyId: "key",
secretAccessKey: "secret",
sessionToken: "session",
region: "eu-west-1",
},
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/v1",
region: "eu-west-1",
profile: "ignored",
credentialProvider: "ignored",
fetch: "ignored",
@@ -163,7 +152,7 @@ describe("AISDKNative", () => {
sessionToken: "session",
region: "eu-west-1",
},
baseURL: "https://bedrock-mantle.eu-west-1.api.aws/v1",
region: "eu-west-1",
providerOptions: { openai: { store: false } },
},
})
@@ -84,19 +84,6 @@ describe("DatabaseMigration", () => {
).rejects.toThrow("Database is not empty and has no session table")
})
test("bootstraps alongside underscore-prefixed embedder tables", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE _embedder_state (id text PRIMARY KEY)`)
yield* DatabaseMigration.apply(db)
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'`)).toEqual(
{ name: "session_v2" },
)
}),
)
})
test("applies generic migrations once and records their order", async () => {
await run(
Effect.gen(function* () {
+1 -116
View File
@@ -123,24 +123,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves environment templates before native providers inspect endpoints", () =>
withEnv({ AZURE_HOST: "resource.openai.azure.com" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
settings: { baseURL: "https://${AZURE_HOST}/openai" },
}),
)
expect(resolved.route.endpoint).toMatchObject({
baseURL: "https://resource.openai.azure.com/openai/v1",
query: { "api-version": "v1" },
})
}),
),
)
it.effect("maps Bedrock Mantle models to native Responses and safeguards to Chat", () =>
Effect.gen(function* () {
const credential = Credential.Key.make({ type: "key", key: "secret" })
@@ -170,46 +152,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("resolves Bedrock Mantle catalog endpoints from the configured region", () =>
withEnv({ AWS_REGION: undefined }, () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
providerID: Provider.ID.amazonBedrock,
modelID: "openai.gpt-5.5",
settings: {
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
expect(resolved.route).toMatchObject({
id: "bedrock-mantle-responses",
endpoint: { baseURL: "https://bedrock-mantle.us-west-2.api.aws/openai/v1" },
})
expect(catalog.settings?.baseURL).toBe("https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1")
}),
),
)
it.effect("prefers the configured Mantle region over the environment", () =>
withEnv({ AWS_REGION: "us-east-1" }, () =>
Effect.gen(function* () {
const resolved = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/amazon-bedrock/mantle"), {
modelID: "openai.gpt-5.5",
settings: {
region: "us-west-2",
baseURL: "https://bedrock-mantle.${AWS_REGION}.api.aws/openai/v1",
},
}),
)
expect(resolved.route.endpoint.baseURL).toBe("https://bedrock-mantle.us-west-2.api.aws/openai/v1")
}),
),
)
it.effect("uses the API modelID instead of the catalog ID for native OpenAI routes", () =>
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
@@ -323,7 +265,7 @@ describe("ModelResolver", () => {
),
)
it.effect("rejects unresolved variables in constructed provider routes", () =>
it.effect("rejects unresolved provider URL variables before route construction", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
@@ -881,63 +823,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("rejects unresolved variables before loading opaque AISDK packages", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
}),
undefined,
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["REQUIRED_HOST"],
})
}),
),
)
it.effect("rejects placeholders introduced by environment expansion before loading providers", () =>
withEnv({ PROVIDER_HOST: "${MISSING_HOST}", MISSING_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/mistral"), {
settings: { baseURL: "https://${PROVIDER_HOST}/v1" },
}),
undefined,
{ loadAISDK: () => Effect.die("AI SDK loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["MISSING_HOST"],
})
}),
),
)
it.effect("rejects unresolved variables before loading native provider packages", () =>
withEnv({ REQUIRED_HOST: undefined }, () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/google"), {
settings: { baseURL: "https://${REQUIRED_HOST}/v1" },
}),
undefined,
{ loadPackage: () => Effect.die("Native package loader should not be called") },
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.UnresolvedProviderVariablesError",
variables: ["REQUIRED_HOST"],
})
}),
),
)
it.effect("rejects AISDK packages without an available loader", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
+6 -21
View File
@@ -228,20 +228,7 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() returns empty catalog when KV is empty, fetch disabled, and the bundled snapshot is disabled", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(
Effect.provide(buildLayer(state, cache, { fetch: false, snapshot: false })),
)
expect(result).toEqual([])
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
)
it.live("get() falls back to the bundled snapshot when KV is empty and fetch is disabled", () =>
it.live("get() returns empty catalog when KV is empty, fetch disabled, and no bundled snapshot is injected", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make(initialState)
@@ -250,9 +237,7 @@ describe("ModelsDev Service", () => {
cache,
ModelsDev.Service.use((s) => s.get()),
)
expect(result.length).toBeGreaterThan(0)
const anthropic = result.find((snapshot) => snapshot.info.id === "anthropic")
expect(anthropic?.environment).toContain("ANTHROPIC_API_KEY")
expect(result).toEqual([])
const final = yield* Ref.get(state)
expect(final.calls).toEqual([])
}),
@@ -263,7 +248,7 @@ describe("ModelsDev Service", () => {
const cache = makeCache()
writeCacheText(cache, "{")
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true, snapshot: false }))
const context = yield* Layer.build(buildLayer(state, cache, { fetch: true }))
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(context))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.get(cacheKey)).toMatchObject({ body: JSON.stringify(fixture2) })
@@ -278,7 +263,7 @@ describe("ModelsDev Service", () => {
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true, snapshot: false })],
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
@@ -296,7 +281,7 @@ describe("ModelsDev Service", () => {
const cache = makeCache()
const state = yield* Ref.make(initialState)
yield* ModelsDev.Service.use((service) => service.get()).pipe(
Effect.provide(buildLayer(state, cache, { url: "", fetch: true, snapshot: false })),
Effect.provide(buildLayer(state, cache, { url: "", fetch: true })),
)
expect((yield* Ref.get(state)).calls[0]?.url).toBe("https://models.opencode.ai/api.json")
}),
@@ -311,7 +296,7 @@ describe("ModelsDev Service", () => {
return yield* Effect.all([svc.get(), svc.get(), svc.get(), svc.get(), svc.get()], {
concurrency: "unbounded",
})
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true, snapshot: false })))
}).pipe(Effect.provide(buildLayer(state, cache, { fetch: true })))
for (const result of results) expect(result).toEqual(fixtureSnapshot)
expect((yield* Ref.get(state)).calls.length).toBe(1)
}),
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test"
import { DateTime, Effect } from "effect"
import { SessionMessage } from "@opencode-ai/core/session/message"
import { SessionMessageRow } from "@opencode-ai/core/session/message-row"
const message = SessionMessage.Synthetic.make({
id: SessionMessage.ID.make("msg_row"),
type: "synthetic",
text: "hello",
time: { created: DateTime.makeUnsafe(1_000) },
})
test("round trips the persisted message representation", async () => {
const row = SessionMessageRow.encode(message)
expect(row.id).toBe(message.id)
expect(row.type).toBe(message.type)
expect(row.data).toHaveProperty("text", message.text)
expect(row.data).toHaveProperty("time.created", 1_000)
expect(await Effect.runPromise(SessionMessageRow.decode(row))).toEqual(message)
expect(SessionMessageRow.decodeSync(row)).toEqual(message)
})
test("canonical columns override stale values in message data", () => {
const row = SessionMessageRow.encode(message)
const data = {
...row.data,
id: SessionMessage.ID.make("msg_stale"),
type: "system" as const,
}
expect(SessionMessageRow.decodeSync({ ...row, data })).toEqual(message)
})
-141
View File
@@ -1,141 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Database } from "bun:sqlite"
import { Effect, Layer } from "effect"
import { SqlClient } from "effect/unstable/sql"
import { SqlError } from "effect/unstable/sql/SqlError"
import { sqliteLayer } from "@opencode-ai/core/database/sqlite.workerd"
import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.workerd"
import { tempGlobalLayer } from "./fixture/global"
// Emulates the Durable Object storage API over bun:sqlite so the adapter can
// be verified without workerd or Cloudflare runtime dependencies.
const makeFakeStorage = () => {
const native = new Database(":memory:")
const toSqlStorageValue = (value: unknown) => {
if (!(value instanceof Uint8Array)) return value as ArrayBuffer | string | number | null
const buffer = new ArrayBuffer(value.byteLength)
new Uint8Array(buffer).set(value)
return buffer
}
const storage: DurableObjectStorage = {
sql: {
exec(query: string, ...bindings: Array<unknown>) {
const statement = native.query(query)
const rows = (statement.values(...(bindings as never[])) ?? []).map((row) => row.map(toSqlStorageValue))
const columnNames = statement.columnNames
return {
columnNames,
raw: () => rows[Symbol.iterator](),
toArray: () => rows.map((row) => Object.fromEntries(columnNames.map((name, i) => [name, row[i]]))),
}
},
},
transaction<T>(closure: (txn: { rollback(): void }) => Promise<T>): Promise<T> {
native.run("BEGIN")
let rolledBack = false
return closure({ rollback: () => (rolledBack = true) }).then(
(result) => {
native.run(rolledBack ? "ROLLBACK" : "COMMIT")
return result
},
(error) => {
native.run("ROLLBACK")
throw error
},
)
},
transactionSync<T>(closure: () => T): T {
return native.transaction(closure)()
},
}
return storage
}
const run = <A, E>(storage: DurableObjectStorage, effect: Effect.Effect<A, E, SqlClient.SqlClient>) =>
Effect.runPromise(effect.pipe(Effect.provide(sqliteLayer({ storage })), Effect.scoped))
describe("sqlite.workerd", () => {
test("executes statements with bindings and maps rows to records", async () => {
const rows = await run(
makeFakeStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE item (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`
yield* sql`INSERT INTO item (id, name) VALUES (${1}, ${"one"}), (${2}, ${"two"})`
return yield* sql<{ id: number; name: string }>`SELECT id, name FROM item ORDER BY id`
}),
)
expect(rows).toEqual([
{ id: 1, name: "one" },
{ id: 2, name: "two" },
])
})
test("normalizes ArrayBuffer blob values to Uint8Array", async () => {
const rows = await run(
makeFakeStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE blob (data BLOB NOT NULL)`
yield* sql`INSERT INTO blob (data) VALUES (${new Uint8Array([1, 2, 3])})`
return yield* sql<{ data: Uint8Array }>`SELECT data FROM blob`
}),
)
expect(rows[0].data).toBeInstanceOf(Uint8Array)
expect(Array.from(rows[0].data)).toEqual([1, 2, 3])
})
test("withTransaction commits on success and rolls back on failure", async () => {
const storage = makeFakeStorage()
const count = await run(
storage,
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
yield* sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"kept"})`)
yield* sql
.withTransaction(
Effect.gen(function* () {
yield* sql`INSERT INTO t (value) VALUES (${"discarded"})`
return yield* Effect.fail("rollback")
}),
)
.pipe(Effect.ignore)
return yield* sql<{ count: number }>`SELECT count(*) AS count FROM t`
}),
)
expect(count[0].count).toBe(1)
})
test("nested withTransaction fails with SqlError", async () => {
const error = await run(
makeFakeStorage(),
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient
yield* sql`CREATE TABLE t (value TEXT NOT NULL)`
return yield* sql
.withTransaction(sql.withTransaction(sql`INSERT INTO t (value) VALUES (${"nested"})`))
.pipe(Effect.flip)
}),
)
expect(error).toBeInstanceOf(SqlError)
})
test("boots the full database layer with migrations over injected storage", async () => {
const storage = makeFakeStorage()
const core = await import("@opencode-ai/core/database/database")
await Effect.runPromise(
Effect.scoped(
Layer.build(
core.Database.layerFromClient.pipe(Layer.provide(sqliteLayer({ storage })), Layer.provide(tempGlobalLayer)),
),
),
)
const names = storage.sql
.exec("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.toArray()
.map((row) => row.name)
expect(names).toContain("migration")
expect(names).toContain("session_v2")
})
})