Compare commits

...

1 Commits

16 changed files with 277 additions and 46 deletions

View File

@@ -1,10 +1,12 @@
import { HttpApi, OpenApi } from "effect/unstable/httpapi"
import { MessageGroup } from "./v2/message"
import { ModelGroup } from "./v2/model"
import { SessionGroup } from "./v2/session"
export const V2Api = HttpApi.make("v2")
.add(SessionGroup)
.add(MessageGroup)
.add(ModelGroup)
.annotateMerge(
OpenApi.annotations({
title: "opencode experimental HttpApi",

View File

@@ -0,0 +1,24 @@
import { ModelV2 } from "@/v2/model"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Authorization } from "../../middleware/authorization"
export const ModelGroup = HttpApiGroup.make("v2.model")
.add(
HttpApiEndpoint.get("models", "/api/model", {
success: Schema.Array(ModelV2.Info),
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.model.list",
summary: "List v2 models",
description: "Retrieve available v2 models ordered by release date.",
}),
),
)
.annotateMerge(
OpenApi.annotations({
title: "v2 models",
description: "Experimental v2 model routes.",
}),
)
.middleware(Authorization)

View File

@@ -1,6 +1,11 @@
import { ModelV2 } from "@/v2/model"
import { SessionV2 } from "@/v2/session"
import { Layer } from "effect"
import { messageHandlers } from "./v2/message"
import { modelHandlers } from "./v2/model"
import { sessionHandlers } from "./v2/session"
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers).pipe(Layer.provide(SessionV2.defaultLayer))
export const v2Handlers = Layer.mergeAll(sessionHandlers, messageHandlers, modelHandlers).pipe(
Layer.provide(ModelV2.defaultLayer),
Layer.provide(SessionV2.defaultLayer),
)

View File

@@ -0,0 +1,12 @@
import { ModelV2 } from "@/v2/model"
import { Effect } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../../api"
export const modelHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.model", (handlers) =>
Effect.gen(function* () {
const model = yield* ModelV2.Service
return handlers.handle("models", () => model.all())
}),
)

View File

@@ -22,7 +22,7 @@ import * as Log from "@opencode-ai/core/util/log"
import { isRecord } from "@/util/record"
import { EventV2 } from "@/v2/event"
import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import { ModelV2 } from "@/v2/model"
import * as DateTime from "effect/DateTime"
const DOOM_LOOP_THRESHOLD = 3
@@ -433,9 +433,9 @@ export const layer: Layer.Layer<
sessionID: ctx.sessionID,
agent: input.assistantMessage.agent,
model: {
id: Modelv2.ID.make(ctx.model.id),
providerID: Modelv2.ProviderID.make(ctx.model.providerID),
variant: Modelv2.VariantID.make(input.assistantMessage.variant ?? "default"),
id: ModelV2.ID.make(ctx.model.id),
providerID: ModelV2.ProviderID.make(ctx.model.providerID),
variant: ModelV2.VariantID.make(input.assistantMessage.variant ?? "default"),
},
snapshot: ctx.snapshot,
timestamp: DateTime.makeUnsafe(Date.now()),

View File

@@ -55,7 +55,7 @@ import { SessionRunState } from "./run-state"
import { EffectBridge } from "@/effect/bridge"
import { EventV2 } from "@/v2/event"
import { SessionEvent } from "@/v2/session-event"
import { Modelv2 } from "@/v2/model"
import { ModelV2 } from "@/v2/model"
import { AgentAttachment, FileAttachment, Source } from "@/v2/session-prompt"
import * as DateTime from "effect/DateTime"
import { eq } from "@/storage/db"
@@ -978,9 +978,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: input.sessionID,
timestamp: DateTime.makeUnsafe(info.time.created),
model: {
id: Modelv2.ID.make(info.model.modelID),
providerID: Modelv2.ProviderID.make(info.model.providerID),
variant: Modelv2.VariantID.make(info.model.variant ?? "default"),
id: ModelV2.ID.make(info.model.modelID),
providerID: ModelV2.ProviderID.make(info.model.providerID),
variant: ModelV2.VariantID.make(info.model.variant ?? "default"),
},
})
}

View File

@@ -2,11 +2,11 @@ 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 const ID = Schema.String.pipe(Schema.brand("ModelV2.ID"))
export type ID = typeof ID.Type
export const ProviderID = Schema.String.pipe(
Schema.brand("Model.ProviderID"),
Schema.brand("ModelV2.ProviderID"),
withStatics((schema) => ({
// Well-known providers
opencode: schema.make("opencode"),
@@ -95,7 +95,7 @@ export const Ref = Schema.Struct({
})
export type Ref = typeof Ref.Type
export class Info extends Schema.Class<Info>("Model.Info")({
export class Info extends Schema.Class<Info>("ModelV2.Info")({
id: ID,
providerID: ProviderID,
family: Family.pipe(Schema.optional),
@@ -151,19 +151,19 @@ export const layer = Layer.effect(
}
const result: Interface = {
get: Effect.fn("V2Model.get")(function* (providerID, modelID) {
get: Effect.fn("ModelV2.get")(function* (providerID, modelID) {
return HashMap.get(models, key(providerID, modelID))
}),
add: Effect.fn("V2Model.add")(function* (model) {
add: Effect.fn("ModelV2.add")(function* (model) {
models = HashMap.set(models, key(model.providerID, model.id), model)
}),
remove: Effect.fn("V2Model.remove")(function* (providerID, modelID) {
remove: Effect.fn("ModelV2.remove")(function* (providerID, modelID) {
models = HashMap.remove(models, key(providerID, modelID))
}),
all: Effect.fn("V2Model.all")(function* () {
all: Effect.fn("ModelV2.all")(function* () {
return pipe(
models,
HashMap.toValues,
@@ -171,12 +171,12 @@ export const layer = Layer.effect(
)
}),
default: Effect.fn("V2Model.default")(function* () {
default: Effect.fn("ModelV2.default")(function* () {
const all = yield* result.all()
return Option.fromUndefinedOr(all[0])
}),
small: Effect.fn("V2Model.small")(function* (providerID) {
small: Effect.fn("ModelV2.small")(function* (providerID) {
const all = yield* result.all()
const match = all.find((model) => model.providerID === providerID && model.id.toLowerCase().includes("small"))
return Option.fromUndefinedOr(match)
@@ -189,4 +189,4 @@ export const layer = Layer.effect(
export const defaultLayer = layer
export * as Modelv2 from "./model"
export * as ModelV2 from "./model"

View File

@@ -0,0 +1,39 @@
import { Context, Effect, HashMap, Layer } from "effect"
import { type Plugin } from "./plugin"
import { ModelV2 } from "./model"
import { AuthV2 } from "./auth"
export * as PluginRegistry from "./plugin-registry"
export interface Interface {
readonly register: (input: { id: Plugin.ID; definition: Plugin.Definition }) => Effect.Effect<void>
readonly trigger: <Name extends keyof Plugin.Hooks>(
name: Name,
input: Parameters<Plugin.Hooks[Name]>[0],
) => Effect.Effect<Parameters<Plugin.Hooks[Name]>[0]>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginRegistry") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
let plugins = HashMap.empty<Plugin.ID, Plugin.Definition>()
const context: Plugin.Context = {
model: yield* ModelV2.Service,
auth: yield* AuthV2.Service,
}
const result = Service.of({
register: Effect.fn("PluginRegistry.register")(function* (input) {
plugins = HashMap.set(plugins, input.id, input.definition)
}),
trigger: Effect.fn("PluginRegistry.trigger")(function* (name, input) {
return input
}),
})
return result
}),
)

View File

@@ -0,0 +1,20 @@
export * as Plugin from "./plugin"
import { ModelV2 } from "./model"
import type { AuthV2 } from "./auth"
import { Effect, Schema } from "effect"
import type { Draft } from "immer"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type
export type Context = {
model: ModelV2.Interface
auth: AuthV2.Interface
}
export type Hooks = {
"model.add": (input: { readonly id: ModelV2.ID; model: Draft<ModelV2.Info> }) => Effect.Effect<void>
}
export type Definition = (context: Context) => Effect.Effect<Hooks>

View File

@@ -6,7 +6,7 @@ import { Schema } from "effect"
export { FileAttachment }
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./schema"
import { Modelv2 } from "./model"
import { ModelV2 } from "./model"
export const Source = Schema.Struct({
start: NonNegativeInt,
@@ -47,7 +47,7 @@ export const ModelSwitched = EventV2.define({
version: 1,
schema: {
...Base,
model: Modelv2.Ref,
model: ModelV2.Ref,
},
})
export type ModelSwitched = Schema.Schema.Type<typeof ModelSwitched>
@@ -104,7 +104,7 @@ export namespace Step {
schema: {
...Base,
agent: Schema.String,
model: Modelv2.Ref,
model: ModelV2.Ref,
snapshot: Schema.String.pipe(Schema.optional),
},
})

View File

@@ -4,7 +4,7 @@ import { SessionEvent } from "./session-event"
import { EventV2 } from "./event"
import { ToolOutput } from "./tool-output"
import { V2Schema } from "./schema"
import { Modelv2 } from "./model"
import { ModelV2 } from "./model"
export const ID = EventV2.ID
export type ID = Schema.Schema.Type<typeof ID>
@@ -26,7 +26,7 @@ export class AgentSwitched extends Schema.Class<AgentSwitched>("Session.Message.
export class ModelSwitched extends Schema.Class<ModelSwitched>("Session.Message.ModelSwitched")({
...Base,
type: Schema.Literal("model-switched"),
model: Modelv2.Ref,
model: ModelV2.Ref,
}) {}
export class User extends Schema.Class<User>("Session.Message.User")({

View File

@@ -11,7 +11,7 @@ import { ProjectID } from "@/project/schema"
import { SessionEvent } from "./session-event"
import { V2Schema } from "./schema"
import { optionalOmitUndefined } from "@/util/schema"
import { Modelv2 } from "./model"
import { ModelV2 } from "./model"
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
identifier: "Session.Delivery",
@@ -27,7 +27,7 @@ export class Info extends Schema.Class<Info>("Session.Info")({
workspaceID: optionalOmitUndefined(WorkspaceID),
path: optionalOmitUndefined(Schema.String),
agent: optionalOmitUndefined(Schema.String),
model: Modelv2.Ref.pipe(optionalOmitUndefined),
model: ModelV2.Ref.pipe(optionalOmitUndefined),
time: Schema.Struct({
created: V2Schema.DateTimeUtcFromMillis,
updated: V2Schema.DateTimeUtcFromMillis,
@@ -56,7 +56,7 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Ses
export interface Interface {
readonly create: (input?: {
agent?: string
model?: Modelv2.Ref
model?: ModelV2.Ref
parentID?: SessionID
workspaceID?: WorkspaceID
}) => Effect.Effect<Info>
@@ -100,10 +100,10 @@ export interface Interface {
parentID: SessionID
prompt: Prompt
agent: string
model?: Modelv2.Ref
model?: ModelV2.Ref
}) => Effect.Effect<void, NotFoundError>
readonly switchAgent: (input: { sessionID: SessionID; agent: string }) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: SessionID; model: Modelv2.Ref }) => Effect.Effect<void, never>
readonly switchModel: (input: { sessionID: SessionID; model: ModelV2.Ref }) => Effect.Effect<void, never>
readonly compact: (sessionID: SessionID) => Effect.Effect<void, never>
readonly wait: (sessionID: SessionID) => Effect.Effect<void, never>
}
@@ -129,9 +129,9 @@ export const layer = Layer.effect(
agent: row.agent ?? undefined,
model: row.model
? {
id: Modelv2.ID.make(row.model.id),
providerID: Modelv2.ProviderID.make(row.model.providerID),
variant: Modelv2.VariantID.make(row.model.variant ?? "default"),
id: ModelV2.ID.make(row.model.id),
providerID: ModelV2.ProviderID.make(row.model.providerID),
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
}
: undefined,
time: {

View File

@@ -19,7 +19,7 @@ import { MessageV2 } from "../../src/session/message-v2"
import { Database } from "@/storage/db"
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
import { SessionMessage } from "../../src/v2/session-message"
import { Modelv2 } from "../../src/v2/model"
import { ModelV2 } from "../../src/v2/model"
import * as DateTime from "effect/DateTime"
import * as Log from "@opencode-ai/core/util/log"
import { eq } from "drizzle-orm"
@@ -216,9 +216,9 @@ describe("session HttpApi", () => {
type: "assistant",
agent: "build",
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
id: ModelV2.ID.make("model"),
providerID: ModelV2.ProviderID.make("provider"),
variant: ModelV2.VariantID.make("default"),
},
time: { created: DateTime.makeUnsafe(1) },
content: [],

View File

@@ -2,7 +2,7 @@ import { expect, test } from "bun:test"
import * as DateTime from "effect/DateTime"
import { SessionID } from "../../src/session/schema"
import { EventV2 } from "../../src/v2/event"
import { Modelv2 } from "../../src/v2/model"
import { ModelV2 } from "../../src/v2/model"
import { SessionEvent } from "../../src/v2/session-event"
import { SessionMessageUpdater } from "../../src/v2/session-message-updater"
@@ -18,9 +18,9 @@ test("step snapshots carry over to assistant messages", () => {
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
id: ModelV2.ID.make("model"),
providerID: ModelV2.ProviderID.make("provider"),
variant: ModelV2.VariantID.make("default"),
},
snapshot: "before",
},
@@ -62,9 +62,9 @@ test("text ended populates assistant text content", () => {
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
id: ModelV2.ID.make("model"),
providerID: ModelV2.ProviderID.make("provider"),
variant: ModelV2.VariantID.make("default"),
},
},
} satisfies SessionEvent.Event)
@@ -106,9 +106,9 @@ test("tool completion stores completed timestamp", () => {
timestamp: DateTime.makeUnsafe(1),
agent: "build",
model: {
id: Modelv2.ID.make("model"),
providerID: Modelv2.ProviderID.make("provider"),
variant: Modelv2.VariantID.make("default"),
id: ModelV2.ID.make("model"),
providerID: ModelV2.ProviderID.make("provider"),
variant: ModelV2.VariantID.make("default"),
},
},
} satisfies SessionEvent.Event)

View File

@@ -193,6 +193,7 @@ import type {
TuiSelectSessionResponses,
TuiShowToastResponses,
TuiSubmitPromptResponses,
V2ModelListResponses,
V2SessionCompactResponses,
V2SessionContextResponses,
V2SessionListErrors,
@@ -4202,11 +4203,48 @@ export class Session3 extends HeyApiClient {
}
}
export class Model extends HeyApiClient {
/**
* List v2 models
*
* Retrieve available v2 models ordered by release date.
*/
public list<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
workspace?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "query", key: "workspace" },
],
},
],
)
return (options?.client ?? this.client).get<V2ModelListResponses, unknown, ThrowOnError>({
url: "/api/model",
...options,
...params,
})
}
}
export class V2 extends HeyApiClient {
private _session?: Session3
get session(): Session3 {
return (this._session ??= new Session3({ client: this.client }))
}
private _model?: Model
get model(): Model {
return (this._model ??= new Model({ client: this.client }))
}
}
export class Control extends HeyApiClient {

View File

@@ -3205,6 +3205,78 @@ export type SessionMessage =
| SessionMessageAssistant
| SessionMessageCompaction
export type ModelV2Info = {
id: string
providerID: string
family?: string
name: string
endpoint:
| {
type: "openai/responses"
url: string
websocket?: boolean
}
| {
type: "openai/completions"
url: string
reasoning?:
| {
type: "reasoning_content"
}
| {
type: "reasoning_details"
}
}
| {
type: "anthropic/messages"
url: string
}
capabilities: {
tools: boolean
input: Array<string>
output: Array<string>
}
options: {
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
variant?: string
}
variants: Array<{
id: string
headers: {
[key: string]: string
}
body: {
[key: string]: unknown
}
}>
time: {
released: number | "NaN" | "Infinity" | "-Infinity" | "Infinity" | "-Infinity" | "NaN"
}
cost: Array<{
tier?: {
type: "context"
size: number
}
input: number
output: number
cache: {
read: number
write: number
}
}>
status: "alpha" | "beta" | "deprecated" | "active"
limit: {
context: number
input?: number
output: number
}
}
export type EventTuiToastShow1 = {
id: string
type: "tui.toast.show"
@@ -6216,6 +6288,25 @@ export type V2SessionMessagesResponses = {
export type V2SessionMessagesResponse2 = V2SessionMessagesResponses[keyof V2SessionMessagesResponses]
export type V2ModelListData = {
body?: never
path?: never
query?: {
directory?: string
workspace?: string
}
url: "/api/model"
}
export type V2ModelListResponses = {
/**
* Success
*/
200: Array<ModelV2Info>
}
export type V2ModelListResponse = V2ModelListResponses[keyof V2ModelListResponses]
export type TuiAppendPromptData = {
body?: {
text: string