Compare commits

...

13 Commits

Author SHA1 Message Date
Dax Raad
f053b52094 core: add Model service for v2 system to track AI providers and capabilities 2026-05-04 00:44:24 -04:00
Dax Raad
897c3001dc core: remove unused AuthV2 import following authentication refactor 2026-05-03 23:11:39 -04:00
Dax Raad
aea638e237 core: streamline authentication by removing redundant AuthV2 layer 2026-05-03 23:09:40 -04:00
Dax Raad
4946e0b1fa core: integrate v2 auth system into app runtime
Adds the AuthV2 layer to the application runtime so the v2 authentication
system is available throughout the app. This ensures auth state is properly
managed and accessible to all components that depend on it.
2026-05-03 23:08:10 -04:00
Dax Raad
033500dae5 core: standardize error types and add operation context to auth file failures
- Replace dynamic error.name with structured 'unknown' type for consistent error reporting
- Add operation context (migrate/write) to auth file write errors for better debugging
- Unify UnknownError schema across session events and tool states
2026-05-03 22:54:50 -04:00
Dax Raad
9ac1ce0c08 core: add account ID prefix and defaultLayer export for v2 auth system
Add 'act' prefix for account identifiers and export defaultLayer from Global

to support the new v2 authentication system that allows multiple accounts

per service with migration from v1 credentials.
2026-05-03 21:27:22 -04:00
Dax Raad
fed24cbda8 fix(tui): improve v2 inline tool error state 2026-05-03 16:11:37 -04:00
Dax Raad
fc801c70ae fix(tui): align v2 inline tool content 2026-05-03 16:11:06 -04:00
Dax Raad
0ed82a1d69 fix(tui): calculate v2 assistant duration from user prompt 2026-05-03 16:11:06 -04:00
Dax Raad
aa73a5941f fix(tui): keep v2 realtime messages newest first 2026-05-03 16:11:06 -04:00
Dax Raad
4b1f77e59c fix(tui): collapse v2 inline tool spacing 2026-05-03 16:11:06 -04:00
Dax Raad
4d9d69526e chore(v2): remove session message model converter 2026-05-03 16:11:06 -04:00
Dax Raad
5b31f6af68 feat(v2): add session message model conversion 2026-05-03 16:11:06 -04:00
9 changed files with 523 additions and 88 deletions

View File

@@ -71,6 +71,8 @@ export const layer = Layer.effect(
Effect.sync(() => Service.of(make())),
)
export const defaultLayer = layer
export const layerWith = (input: Partial<Interface>) =>
Layer.effect(
Service,

View File

@@ -11,21 +11,21 @@ import { createSimpleContext } from "./helper"
import { useSDK } from "./sdk"
function activeAssistant(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "assistant" && !message.time.completed)
const index = messages.findIndex((message) => message.type === "assistant" && !message.time.completed)
if (index < 0) return
const assistant = messages[index]
return assistant?.type === "assistant" ? assistant : undefined
}
function activeCompaction(messages: SessionMessage[]) {
const index = messages.findLastIndex((message) => message.type === "compaction")
const index = messages.findIndex((message) => message.type === "compaction")
if (index < 0) return
const compaction = messages[index]
return compaction?.type === "compaction" ? compaction : undefined
}
function activeShell(messages: SessionMessage[], callID: string) {
const index = messages.findLastIndex((message) => message.type === "shell" && message.callID === callID)
const index = messages.findIndex((message) => message.type === "shell" && message.callID === callID)
if (index < 0) return
const shell = messages[index]
return shell?.type === "shell" ? shell : undefined
@@ -74,7 +74,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
switch (event.type) {
case "session.next.prompted": {
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "user",
text: event.properties.prompt.text,
@@ -87,7 +87,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
}
case "session.next.synthetic":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "synthetic",
sessionID: event.properties.sessionID,
@@ -98,7 +98,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.shell.started":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "shell",
callID: event.properties.callID,
@@ -120,7 +120,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
update(event.properties.sessionID, (draft) => {
const currentAssistant = activeAssistant(draft)
if (currentAssistant) currentAssistant.time.completed = event.properties.timestamp
draft.push({
draft.unshift({
id: event.id,
type: "assistant",
agent: event.properties.agent,
@@ -259,7 +259,7 @@ export const { use: useSyncV2, provider: SyncProviderV2 } = createSimpleContext(
break
case "session.next.compaction.started":
update(event.properties.sessionID, (draft) => {
draft.push({
draft.unshift({
id: event.id,
type: "compaction",
reason: event.properties.reason,

View File

@@ -5,7 +5,7 @@ import { Spinner } from "@tui/component/spinner"
import { useTheme } from "@tui/context/theme"
import { useLocal } from "@tui/context/local"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import type { SyntaxStyle } from "@opentui/core"
import { TextAttributes, type BoxRenderable, type SyntaxStyle } from "@opentui/core"
import { Locale } from "@/util/locale"
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
import path from "path"
@@ -44,6 +44,10 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
const messages = createMemo(() => sync.data.messages[props.sessionID] ?? [])
const renderedMessages = createMemo(() => messages().toReversed())
const lastAssistant = createMemo(() => renderedMessages().findLast((message) => message.type === "assistant"))
const lastUserCreated = (index: number) =>
renderedMessages()
.slice(0, index)
.findLast((message) => message.type === "user")?.time.created
createEffect(() => {
void sync.session.message.sync(props.sessionID)
@@ -83,10 +87,11 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
last={lastAssistant()?.id === message.id}
syntax={syntax()}
subtleSyntax={subtleSyntax()}
start={lastUserCreated(index())}
/>
</Match>
<Match when={message.type === "synthetic"}>
<SyntheticMessage message={message as SessionMessageSynthetic} index={index()} />
<></>
</Match>
<Match when={message.type === "shell"}>
<ShellMessage message={message as SessionMessageShell} />
@@ -146,63 +151,36 @@ function UserMessage(props: { message: SessionMessageUser; index: number }) {
<box
id={props.message.id}
border={["left"]}
borderColor={theme.primary}
borderColor={theme.secondary}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
flexShrink={0}
>
<box paddingTop={1} paddingBottom={1} paddingLeft={2} backgroundColor={theme.backgroundPanel}>
<Show
when={props.message.text.trim()}
fallback={
<MissingData label="User message text" detail={`Message ${props.message.id} has no text field content.`} />
}
>
<text fg={theme.text}>{props.message.text}</text>
</Show>
<Show when={attachments().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files ?? []}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)}
</For>
<For each={props.message.agents ?? []}>
{(agent) => (
<text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
</text>
)}
</For>
</box>
</Show>
<text fg={theme.textMuted}>{Locale.todayTimeOrDateTime(props.message.time.created)}</text>
</box>
</box>
)
}
function SyntheticMessage(props: { message: SessionMessageSynthetic; index: number }) {
const { theme } = useTheme()
return (
<box
id={props.message.id}
border={["left"]}
borderColor={theme.backgroundElement}
customBorderChars={SplitBorder.customBorderChars}
marginTop={props.index === 0 ? 0 : 1}
paddingLeft={2}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
backgroundColor={theme.backgroundPanel}
flexShrink={0}
>
<text fg={theme.textMuted}>Synthetic</text>
<text fg={theme.text}>{props.message.text}</text>
<Show when={attachments().length}>
<box flexDirection="row" paddingTop={1} gap={1} flexWrap="wrap">
<For each={props.message.files ?? []}>
{(file) => (
<text fg={theme.text}>
<span style={{ bg: theme.secondary, fg: theme.background }}> {file.mime} </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.name ?? file.uri} </span>
</text>
)}
</For>
<For each={props.message.agents ?? []}>
{(agent) => (
<text fg={theme.text}>
<span style={{ bg: theme.accent, fg: theme.background }}> agent </span>
<span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {agent.name} </span>
</text>
)}
</For>
</box>
</Show>
</box>
)
}
@@ -237,7 +215,7 @@ function ShellMessage(props: { message: SessionMessageShell }) {
}
function CompactionMessage(props: { message: SessionMessageCompaction }) {
const { theme } = useTheme()
const { theme, syntax } = useTheme()
return (
<box
marginTop={1}
@@ -248,7 +226,19 @@ function CompactionMessage(props: { message: SessionMessageCompaction }) {
flexShrink={0}
>
<Show when={props.message.summary}>
<text fg={theme.textMuted}>{props.message.summary}</text>
{(summary) => (
<box paddingLeft={3} paddingTop={1}>
<code
filetype="markdown"
drawUnstyledText={false}
streaming={false}
syntaxStyle={syntax()}
content={summary().trim()}
conceal={true}
fg={theme.text}
/>
</box>
)}
</Show>
</box>
)
@@ -294,12 +284,13 @@ function AssistantMessage(props: {
last: boolean
syntax: SyntaxStyle
subtleSyntax: SyntaxStyle
start?: number
}) {
const { theme } = useTheme()
const local = useLocal()
const duration = createMemo(() => {
if (!props.message.time.completed) return 0
return props.message.time.completed - props.message.time.created
return props.message.time.completed - (props.start ?? props.message.time.created)
})
const model = createMemo(() => {
const variant = props.message.model.variant ? `/${props.message.model.variant}` : ""
@@ -361,7 +352,7 @@ function AssistantText(props: { part: SessionMessageAssistantText; syntax: Synta
const { theme } = useTheme()
return (
<Show when={props.part.text.trim()}>
<box paddingLeft={3} marginTop={1} flexShrink={0}>
<box paddingLeft={3} marginTop={1} flexShrink={0} id="text">
<code
filetype="markdown"
drawUnstyledText={false}
@@ -521,33 +512,93 @@ function InlineTool(props: {
part: SessionMessageAssistantTool
}) {
const { theme } = useTheme()
const renderer = useRenderer()
const [margin, setMargin] = createSignal(0)
const [hover, setHover] = createSignal(false)
const [showError, setShowError] = createSignal(false)
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
const complete = createMemo(() => !!props.complete)
const denied = createMemo(() => {
const message = error()
if (!message) return false
return (
message.includes("QuestionRejectedError") ||
message.includes("rejected permission") ||
message.includes("specified a rule") ||
message.includes("user dismissed")
)
})
const fg = createMemo(() => {
if (error()) return theme.error
if (complete()) return theme.textMuted
return theme.text
})
const attributes = createMemo(() => (denied() ? TextAttributes.STRIKETHROUGH : undefined))
return (
<box marginTop={1} paddingLeft={3} flexShrink={0}>
<Switch>
<Match when={props.spinner}>
<Spinner color={theme.text}>{props.children}</Spinner>
</Match>
<Match when={true}>
<text paddingLeft={3} fg={props.complete ? theme.textMuted : theme.text}>
<Show fallback={<>~ {props.pending}</>} when={props.complete}>
{props.icon} {props.children}
</Show>
</text>
</Match>
</Switch>
<Show when={error() && !denied()}>
<text fg={theme.error}>{error()}</text>
</Show>
<box
marginTop={margin()}
paddingLeft={3}
flexShrink={0}
flexDirection="row"
gap={1}
backgroundColor={hover() && error() ? theme.backgroundMenu : undefined}
onMouseOver={() => error() && setHover(true)}
onMouseOut={() => setHover(false)}
onMouseUp={() => {
if (!error()) return
if (renderer.getSelection()?.getSelectedText()) return
setShowError((prev) => !prev)
}}
renderBefore={function () {
const el = this as BoxRenderable
const parent = el.parent
if (!parent) return
const previous = parent.getChildren()[parent.getChildren().indexOf(el) - 1]
if (!previous) {
setMargin(0)
return
}
if (previous.id.startsWith("text")) setMargin(1)
}}
>
<box flexShrink={0}>
<Switch>
<Match when={props.spinner}>
<Spinner color={theme.text} />
</Match>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.icon}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
~
</text>
</Match>
</Switch>
</box>
<box flexGrow={1}>
<box>
<Switch>
<Match when={complete()}>
<text fg={fg()} attributes={attributes()}>
{props.children}
</text>
</Match>
<Match when={true}>
<text fg={fg()} attributes={attributes()}>
{props.pending}
</text>
</Match>
</Switch>
</box>
<Show when={showError() && error()}>
<box>
<text fg={theme.error}>{error()}</text>
</box>
</Show>
</box>
</box>
)
}

View File

@@ -13,6 +13,7 @@ const prefixes = {
tool: "tool",
workspace: "wrk",
entry: "ent",
account: "act",
} as const
export function schema(prefix: keyof typeof prefixes) {

View File

@@ -655,7 +655,7 @@ export const layer: Layer.Layer<
EventV2.run(SessionEvent.Step.Failed.Sync, {
sessionID: ctx.sessionID,
error: {
type: error.name,
type: "unknown",
message: errorMessage(e),
},
timestamp: DateTime.makeUnsafe(Date.now()),

View File

@@ -0,0 +1,246 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "@opencode-ai/core/util/identifier"
import { NonNegativeInt, withStatics } from "@/util/schema"
import { Global } from "@opencode-ai/core/global"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
const AccountID = Schema.String.pipe(
Schema.brand("AccountID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type AccountID = typeof AccountID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AuthV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AuthV2.ApiKeyCredential")({
type: Schema.Literal("api"),
key: Schema.String,
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}) {}
export const Credential = Schema.Union([OAuthCredential, ApiKeyCredential])
.pipe(Schema.toTaggedUnion("type"))
.annotate({
identifier: "AuthV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Account extends Schema.Class<Account>("AuthV2.Account")({
id: AccountID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class AuthFileWriteError extends Schema.TaggedErrorClass<AuthFileWriteError>()("AuthV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type AuthError = AuthFileWriteError
interface Writable {
version: 2
accounts: Record<string, Account>
active: Record<string, AccountID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Account> = {}
const active: Record<string, AccountID> = {}
for (const [serviceID, value] of Object.entries(old)) {
const decoded = Option.getOrElse(decodeV1({ [serviceID]: value }), () => ({}))
const parsed = (decoded as Record<string, Credential>)[serviceID]
if (!parsed) continue
const id = Identifier.ascending()
const accountID = AccountID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Account({
id: accountID,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = accountID
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (accountID: AccountID) => Effect.Effect<Account | undefined, AuthError>
readonly all: () => Effect.Effect<Account[], AuthError>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
active?: boolean
}) => Effect.Effect<Account, AuthError>
readonly update: (
accountID: AccountID,
updates: Partial<Pick<Account, "description" | "credential">>,
) => Effect.Effect<void, AuthError>
readonly remove: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly activate: (accountID: AccountID) => Effect.Effect<void, AuthError>
readonly active: (serviceID: ServiceID) => Effect.Effect<Account | undefined, AuthError>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Account[], AuthError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Auth") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const file = path.join(global.data, "auth-v2.json")
const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT)
} catch {}
}
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (!raw || typeof raw !== "object") return { version: 2, accounts: {}, active: {} }
if ("version" in raw && raw.version === 2) return raw as Writable
const migrated = migrate(raw as Record<string, unknown>)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
return migrated
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(yield* load())
const result: Interface = {
get: Effect.fn("AuthV2.get")(function* (accountID) {
return (yield* SynchronizedRef.get(state)).accounts[accountID]
}),
all: Effect.fn("AuthV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AuthV2.active")(function* (serviceID) {
const data = yield* SynchronizedRef.get(state)
return (
data.accounts[data.active[serviceID]] ?? Object.values(data.accounts).find((a) => a.serviceID === serviceID)
)
}),
forService: Effect.fn("AuthV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AuthV2.add")(function* (input) {
return yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = new Account({
id: AccountID.make(Identifier.ascending()),
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active:
(input.active ?? Object.values(data.accounts).every((a) => a.serviceID !== input.serviceID))
? { ...data.active, [input.serviceID]: account.id }
: data.active,
}
yield* write(next)
return [account, next] as const
}),
)
}),
update: Effect.fn("AuthV2.update")(function* (accountID, updates) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const existing = data.accounts[accountID]
if (!existing) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[accountID]: new Account({
id: accountID,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AuthV2.remove")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
if (accounts[accountID] && active[accounts[accountID].serviceID] === accountID)
delete active[accounts[accountID].serviceID]
delete accounts[accountID]
const next = { ...data, accounts, active }
yield* write(next)
return [undefined, next] as const
}),
)
}),
activate: Effect.fn("AuthV2.activate")(function* (accountID) {
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const account = data.accounts[accountID]
if (!account) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [account.serviceID]: accountID } }
yield* write(next)
return [undefined, next] as const
}),
)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Global.defaultLayer))
export * as AuthV2 from "./auth"

View File

@@ -0,0 +1,135 @@
import { withStatics } from "@/util/schema"
import { Array, Context, Effect, HashMap, Layer, Option, Order, pipe, Schema } from "effect"
import { DateTimeUtcFromMillis } from "effect/Schema"
export const ID = Schema.String.pipe(Schema.brand("Model.ID"))
export type ID = typeof ID.Type
export const ProviderID = Schema.String.pipe(
Schema.brand("Model.ProviderID"),
withStatics((schema) => ({
// Well-known providers
opencode: schema.make("opencode"),
anthropic: schema.make("anthropic"),
openai: schema.make("openai"),
google: schema.make("google"),
googleVertex: schema.make("google-vertex"),
githubCopilot: schema.make("github-copilot"),
amazonBedrock: schema.make("amazon-bedrock"),
azure: schema.make("azure"),
openrouter: schema.make("openrouter"),
mistral: schema.make("mistral"),
gitlab: schema.make("gitlab"),
})),
)
export type ProviderID = typeof ProviderID.Type
export const ApiFormat = Schema.Union([
Schema.Literal("openai/responses"),
Schema.Literal("openai/completions"),
Schema.Literal("anthropic"),
])
const Modalities = Schema.Struct({
text: Schema.Boolean,
audio: Schema.Boolean,
image: Schema.Boolean,
video: Schema.Boolean,
pdf: Schema.Boolean,
})
export const Capabilities = Schema.Struct({
temperature: Schema.Boolean,
reasoning: Schema.Boolean,
attachment: Schema.Boolean,
toolcall: Schema.Boolean,
small: Schema.Boolean,
input: Modalities,
output: Modalities,
})
export class Info extends Schema.Class<Info>("Model.Info")({
id: ID,
providerID: ProviderID,
api: Schema.Struct({
format: ApiFormat,
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
}),
capabilities: Capabilities,
name: Schema.String,
family: Schema.optional(Schema.String),
variants: Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Any)),
time: Schema.Struct({
released: DateTimeUtcFromMillis,
}),
}) {}
export function parse(input: string): { providerID: ProviderID; modelID: ID } {
const [providerID, ...modelID] = input.split("/")
return {
providerID: ProviderID.make(providerID),
modelID: ID.make(modelID.join("/")),
}
}
export interface Interface {
readonly get: (providerID: ProviderID, modelID: ID) => Effect.Effect<Option.Option<Info>>
readonly add: (model: Info) => Effect.Effect<void>
readonly remove: (providerID: ProviderID, modelID: ID) => Effect.Effect<void>
readonly all: () => Effect.Effect<Info[]>
readonly default: () => Effect.Effect<Option.Option<Info>>
readonly small: (provider: ProviderID) => Effect.Effect<Option.Option<Info>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Model") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
let models = HashMap.empty<string, Info>()
function key(providerID: ProviderID, modelID: ID) {
return `${providerID}/${modelID}`
}
const result: Interface = {
get: Effect.fn("V2Model.get")(function* (providerID, modelID) {
return HashMap.get(models, key(providerID, modelID))
}),
add: Effect.fn("V2Model.add")(function* (model) {
models = HashMap.set(models, key(model.providerID, model.id), model)
}),
remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) {
models = HashMap.remove(models, key(providerID, modelID))
}),
all: Effect.fn("V2Model.all")(function* () {
return pipe(
models,
HashMap.toValues,
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
}),
default: Effect.fn("V2Model.default")(function* () {
const all = yield* result.all()
return Option.fromUndefinedOr(all[0])
}),
small: Effect.fn("V2Model.small")(function* (providerID) {
const all = yield* result.all()
const match = all.find((model) => model.capabilities.small && model.providerID === providerID)
return Option.fromUndefinedOr(match)
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer
export * as Modelv2 from "./model"

View File

@@ -22,10 +22,13 @@ const Base = {
sessionID: SessionID,
}
const Error = Schema.Struct({
type: Schema.String,
export const UnknownError = Schema.Struct({
type: Schema.Literal("unknown"),
message: Schema.String,
}).annotate({
identifier: "Session.Error.Unknown",
})
export type UnknownError = Schema.Schema.Type<typeof UnknownError>
export const AgentSwitched = EventV2.define({
type: "session.next.agent.switched",
@@ -139,7 +142,7 @@ export namespace Step {
aggregate: "sessionID",
schema: {
...Base,
error: Error,
error: UnknownError,
},
})
export type Failed = Schema.Schema.Type<typeof Failed>
@@ -296,7 +299,7 @@ export namespace Tool {
schema: {
...Base,
callID: Schema.String,
error: Error,
error: UnknownError,
provider: Schema.Struct({
executed: Schema.Boolean,
metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),

View File

@@ -87,10 +87,7 @@ export class ToolStateError extends Schema.Class<ToolStateError>("Session.Messag
input: Schema.Record(Schema.String, Schema.Unknown),
content: ToolOutput.Content.pipe(Schema.Array),
structured: ToolOutput.Structured,
error: Schema.Struct({
type: Schema.String,
message: Schema.String,
}),
error: SessionEvent.UnknownError,
}) {}
export const ToolState = Schema.Union([ToolStatePending, ToolStateRunning, ToolStateCompleted, ToolStateError]).pipe(