core: add v2 provider model API
This commit is contained in:
@@ -1,246 +0,0 @@
|
||||
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 "@opencode-ai/core/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"
|
||||
@@ -1,193 +0,0 @@
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import { ModelStatus } from "@/provider/model-status"
|
||||
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 VariantID = Schema.String.pipe(Schema.brand("VariantID"))
|
||||
export type VariantID = typeof VariantID.Type
|
||||
|
||||
// Grouping of models, eg claude opus, claude sonnet
|
||||
export const Family = Schema.String.pipe(Schema.brand("Family"))
|
||||
export type Family = typeof Family.Type
|
||||
|
||||
const OpenAIResponses = Schema.Struct({
|
||||
type: Schema.Literal("openai/responses"),
|
||||
url: Schema.String,
|
||||
websocket: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const OpenAICompletions = Schema.Struct({
|
||||
type: Schema.Literal("openai/completions"),
|
||||
url: Schema.String,
|
||||
reasoning: Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("reasoning_content"),
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("reasoning_details"),
|
||||
}),
|
||||
]).pipe(Schema.optional),
|
||||
})
|
||||
export type OpenAICompletions = typeof OpenAICompletions.Type
|
||||
|
||||
const AnthropicMessages = Schema.Struct({
|
||||
type: Schema.Literal("anthropic/messages"),
|
||||
url: Schema.String,
|
||||
})
|
||||
|
||||
export const Endpoint = Schema.Union([OpenAIResponses, OpenAICompletions, AnthropicMessages]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type Endpoint = typeof Endpoint.Type
|
||||
|
||||
export const Capabilities = Schema.Struct({
|
||||
tools: Schema.Boolean,
|
||||
// mime patterns, image, audio, video/*, text/*
|
||||
input: Schema.String.pipe(Schema.Array),
|
||||
output: Schema.String.pipe(Schema.Array),
|
||||
})
|
||||
export type Capabilities = typeof Capabilities.Type
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
headers: Schema.Record(Schema.String, Schema.String),
|
||||
body: Schema.Record(Schema.String, Schema.Any),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export const Cost = Schema.Struct({
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Int,
|
||||
}).pipe(Schema.optional),
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: Schema.Struct({
|
||||
read: Schema.Finite,
|
||||
write: Schema.Finite,
|
||||
}),
|
||||
})
|
||||
|
||||
export const Ref = Schema.Struct({
|
||||
id: ID,
|
||||
providerID: ProviderID,
|
||||
variant: VariantID,
|
||||
})
|
||||
export type Ref = typeof Ref.Type
|
||||
|
||||
export class Info extends Schema.Class<Info>("Model.Info")({
|
||||
id: ID,
|
||||
providerID: ProviderID,
|
||||
family: Family.pipe(Schema.optional),
|
||||
name: Schema.String,
|
||||
endpoint: Endpoint,
|
||||
capabilities: Capabilities,
|
||||
options: Schema.Struct({
|
||||
...Options.fields,
|
||||
variant: Schema.String.pipe(Schema.optional),
|
||||
}),
|
||||
variants: Schema.Struct({
|
||||
id: VariantID,
|
||||
...Options.fields,
|
||||
}).pipe(Schema.Array),
|
||||
time: Schema.Struct({
|
||||
released: DateTimeUtcFromMillis,
|
||||
}),
|
||||
cost: Cost.pipe(Schema.Array),
|
||||
status: ModelStatus,
|
||||
limit: Schema.Struct({
|
||||
context: Schema.Int,
|
||||
input: Schema.Int.pipe(Schema.optional),
|
||||
output: Schema.Int,
|
||||
}),
|
||||
}) {}
|
||||
|
||||
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.providerID === providerID && model.id.toLowerCase().includes("small"))
|
||||
return Option.fromUndefinedOr(match)
|
||||
}),
|
||||
}
|
||||
|
||||
return Service.of(result)
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer
|
||||
|
||||
export * as Modelv2 from "./model"
|
||||
@@ -0,0 +1,50 @@
|
||||
export * as PluginBoot from "./plugin-boot"
|
||||
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AuthV2 } from "@opencode-ai/core/auth"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
|
||||
import { EnvPlugin } from "@opencode-ai/core/plugin/env"
|
||||
import { ModelsDevPlugin } from "./plugin/models-dev"
|
||||
import { ProviderPlugins } from "./plugin/provider"
|
||||
|
||||
type Plugin = {
|
||||
id: PluginV2.ID
|
||||
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
|
||||
}
|
||||
|
||||
export const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const plugin = yield* PluginV2.Service
|
||||
const auth = yield* AuthV2.Service
|
||||
const npm = yield* Npm.Service
|
||||
|
||||
const add = Effect.fn("PluginBoot.add")(function* (input: Plugin) {
|
||||
yield* plugin.add({
|
||||
id: input.id,
|
||||
effect: input.effect.pipe(
|
||||
Effect.provideService(Catalog.Service, catalog),
|
||||
Effect.provideService(AuthV2.Service, auth),
|
||||
Effect.provideService(Npm.Service, npm),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
yield* add(EnvPlugin)
|
||||
yield* add(AuthPlugin)
|
||||
for (const item of ProviderPlugins) {
|
||||
yield* add(item)
|
||||
}
|
||||
yield* add(ModelsDevPlugin)
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Catalog.defaultLayer),
|
||||
Layer.provide(PluginV2.defaultLayer),
|
||||
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
import { DateTime, Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { ModelsDev } from "@/provider/models"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
|
||||
function released(date: string) {
|
||||
const time = Date.parse(date)
|
||||
return DateTime.makeUnsafe(Number.isFinite(time) ? time : 0)
|
||||
}
|
||||
|
||||
function cost(input: ModelsDev.Model["cost"]) {
|
||||
const base = {
|
||||
input: input?.input ?? 0,
|
||||
output: input?.output ?? 0,
|
||||
cache: {
|
||||
read: input?.cache_read ?? 0,
|
||||
write: input?.cache_write ?? 0,
|
||||
},
|
||||
}
|
||||
if (!input?.context_over_200k) return [base]
|
||||
return [
|
||||
base,
|
||||
{
|
||||
tier: {
|
||||
type: "context" as const,
|
||||
size: 200_000,
|
||||
},
|
||||
input: input.context_over_200k.input,
|
||||
output: input.context_over_200k.output,
|
||||
cache: {
|
||||
read: input.context_over_200k.cache_read ?? 0,
|
||||
write: input.context_over_200k.cache_write ?? 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function variants(model: ModelsDev.Model) {
|
||||
return Object.entries(model.experimental?.modes ?? {}).map(([id, item]) => ({
|
||||
id: ModelV2.VariantID.make(id),
|
||||
headers: { ...(item.provider?.headers ?? {}) },
|
||||
body: { ...(item.provider?.body ?? {}) },
|
||||
aisdk: {
|
||||
provider: {},
|
||||
request: {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export const ModelsDevPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("models-dev"),
|
||||
effect: Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
for (const item of Object.values(yield* modelsDev.get())) {
|
||||
const providerID = ProviderV2.ID.make(item.id)
|
||||
yield* catalog.provider.update(providerID, (provider) => {
|
||||
provider.name = item.name
|
||||
provider.env = [...item.env]
|
||||
provider.endpoint = item.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
package: item.npm,
|
||||
url: item.api,
|
||||
}
|
||||
: {
|
||||
type: "unknown",
|
||||
}
|
||||
})
|
||||
|
||||
for (const model of Object.values(item.models)) {
|
||||
const modelID = ModelV2.ID.make(model.id)
|
||||
yield* catalog.model
|
||||
.update(providerID, modelID, (draft) => {
|
||||
draft.name = model.name
|
||||
draft.family = model.family ? ModelV2.Family.make(model.family) : undefined
|
||||
draft.endpoint = model.provider?.npm
|
||||
? {
|
||||
type: "aisdk",
|
||||
package: model.provider?.npm,
|
||||
url: model.provider.api,
|
||||
}
|
||||
: {
|
||||
type: "unknown",
|
||||
}
|
||||
draft.capabilities = {
|
||||
tools: model.tool_call,
|
||||
input: [...(model.modalities?.input ?? [])],
|
||||
output: [...(model.modalities?.output ?? [])],
|
||||
}
|
||||
draft.variants = variants(model)
|
||||
draft.time.released = released(model.release_date)
|
||||
draft.cost = cost(model.cost)
|
||||
draft.status = model.status ?? "active"
|
||||
draft.enabled = true
|
||||
draft.limit = {
|
||||
context: model.limit.context,
|
||||
input: model.limit.input,
|
||||
output: model.limit.output,
|
||||
}
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
}
|
||||
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
function shouldUseResponses(modelID: string) {
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
// which still need the chat-completions endpoint.
|
||||
const match = /^gpt-(\d+)/.exec(modelID)
|
||||
if (!match) return false
|
||||
return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
|
||||
}
|
||||
|
||||
export const GithubCopilotPlugin = PluginV2.define({
|
||||
id: PluginV2.ID.make("github-copilot"),
|
||||
effect: Effect.gen(function* () {
|
||||
return {
|
||||
"provider.update": Effect.fn(function* (evt) {
|
||||
if (evt.provider.id !== ProviderV2.ID.githubCopilot) return
|
||||
}),
|
||||
"aisdk.sdk": Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
const mod = yield* Effect.promise(() => import("../../../provider/sdk/copilot/copilot-provider"))
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
"aisdk.language": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
|
||||
evt.language = evt.sdk.languageModel(evt.model.apiID)
|
||||
return
|
||||
}
|
||||
evt.language = shouldUseResponses(evt.model.apiID)
|
||||
? evt.sdk.responses(evt.model.apiID)
|
||||
: evt.sdk.chat(evt.model.apiID)
|
||||
}),
|
||||
"model.update": Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
|
||||
}),
|
||||
}
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ProviderPlugins as CoreProviderPlugins } from "@opencode-ai/core/plugin/provider"
|
||||
import { GithubCopilotPlugin } from "./github-copilot"
|
||||
|
||||
export const ProviderPlugins = [
|
||||
...CoreProviderPlugins,
|
||||
GithubCopilotPlugin,
|
||||
]
|
||||
@@ -0,0 +1,95 @@
|
||||
# Unported Provider Logic Checklist
|
||||
|
||||
This tracks legacy provider behavior from `packages/opencode/src/provider/provider.ts` that still needs to be ported into the v2 provider plugins under `packages/opencode/src/v2/plugin/provider/`. Keep entries checked only when v2 has equivalent behavior or when the item is intentionally skipped.
|
||||
|
||||
## Provider Setup
|
||||
|
||||
- [x] Cloudflare AI Gateway custom SDK construction with `createAiGateway` / `createUnified`.
|
||||
- [x] Google Vertex authenticated `fetch` injection.
|
||||
- [x] Amazon Bedrock AWS credential chain setup.
|
||||
- [x] Amazon Bedrock bearer token setup.
|
||||
- [x] SAP AI Core service key setup.
|
||||
|
||||
## Provider Options
|
||||
|
||||
- [x] Azure resource name resolution.
|
||||
- [x] Azure missing-resource error.
|
||||
- [x] Azure Cognitive Services baseURL resolution.
|
||||
- [x] Cloudflare Workers AI account ID validation.
|
||||
- [x] Cloudflare Workers AI account ID vars.
|
||||
- [x] Cloudflare AI Gateway account ID validation.
|
||||
- [x] Cloudflare AI Gateway gateway ID validation.
|
||||
- [x] Cloudflare AI Gateway token validation.
|
||||
- [x] Amazon Bedrock region precedence.
|
||||
- [x] Amazon Bedrock profile precedence.
|
||||
- [x] Amazon Bedrock endpoint precedence.
|
||||
- [x] Google Vertex project resolution.
|
||||
- [x] Google Vertex location resolution.
|
||||
- [x] GitLab instance URL resolution.
|
||||
- [x] GitLab token resolution.
|
||||
- [x] GitLab AI gateway headers.
|
||||
- [x] GitLab feature flags.
|
||||
- [x] Opencode unauthenticated paid-model filtering.
|
||||
- [x] Opencode public API key fallback.
|
||||
|
||||
## Request Behavior
|
||||
|
||||
- [x] Request timeout handling.
|
||||
- [x] Chunk timeout handling.
|
||||
- [x] SSE timeout wrapping.
|
||||
- [x] OpenAI response item ID stripping.
|
||||
- [x] Azure response item ID stripping.
|
||||
- [x] OpenAI-compatible `includeUsage` defaulting.
|
||||
|
||||
## Dynamic Models
|
||||
|
||||
- [ ] GitLab workflow model discovery.
|
||||
|
||||
## Model Filtering
|
||||
|
||||
- [ ] Experimental alpha model filtering.
|
||||
- [ ] Deprecated model filtering.
|
||||
- [ ] Config whitelist filtering.
|
||||
- [ ] Config blacklist filtering.
|
||||
- [ ] `gpt-5-chat-latest` filtering.
|
||||
- [ ] OpenRouter `openai/gpt-5-chat` filtering.
|
||||
|
||||
## Default Models
|
||||
|
||||
- [x] Configured default model selection. Replaced by explicit `Catalog.model.setDefault`.
|
||||
- [SKIP] Recent-history default model selection — not porting to server-side v2 catalog.
|
||||
- [x] Default model fallback sorting. Uses newest available model, not legacy hard-coded priority.
|
||||
|
||||
## Small Models
|
||||
|
||||
- [SKIP] Configured `small_model` selection — not porting config-driven selection to server-side v2 catalog.
|
||||
- [x] Provider-specific small model priority. Replaced by cheapest output cost selection.
|
||||
- [x] Opencode small model priority. Replaced by cheapest output cost selection.
|
||||
- [x] GitHub Copilot small model priority. Replaced by cheapest output cost selection.
|
||||
- [x] Amazon Bedrock region-aware small model selection. Replaced by cheapest output cost selection.
|
||||
|
||||
## URL And Env Vars
|
||||
|
||||
- [SKIP] BaseURL `${VAR}` interpolation — not porting generic URL templating; provider plugins should construct concrete URLs.
|
||||
- [x] Azure `AZURE_RESOURCE_NAME` vars. Handled by Azure provider plugins.
|
||||
- [x] Google Vertex vars. Handled by Google Vertex provider plugins.
|
||||
- [x] Cloudflare Workers AI vars. Handled by Cloudflare Workers AI provider plugin.
|
||||
|
||||
## Auth
|
||||
|
||||
- [ ] Auth-derived provider API keys.
|
||||
- [ ] OpenAI OAuth/API auth distinction.
|
||||
- [ ] GitLab OAuth token selection.
|
||||
- [ ] GitLab API token selection.
|
||||
- [ ] Azure auth metadata resource name.
|
||||
- [ ] Cloudflare auth metadata account ID.
|
||||
- [ ] Cloudflare auth metadata gateway ID.
|
||||
|
||||
## Config And Plugin Parity
|
||||
|
||||
- [ ] Legacy plugin auth loader behavior.
|
||||
- [ ] Config provider merge behavior.
|
||||
- [ ] Config model merge behavior.
|
||||
- [ ] Variant generation from model metadata.
|
||||
- [ ] Config variant merge behavior.
|
||||
- [ ] Config variant disable behavior.
|
||||
@@ -1,10 +0,0 @@
|
||||
import { DateTime, Schema, SchemaGetter } from "effect"
|
||||
|
||||
export const DateTimeUtcFromMillis = Schema.Finite.pipe(
|
||||
Schema.decodeTo(Schema.DateTimeUtc, {
|
||||
decode: SchemaGetter.transform((value) => DateTime.makeUnsafe(value)),
|
||||
encode: SchemaGetter.transform((value) => DateTime.toEpochMillis(value)),
|
||||
}),
|
||||
)
|
||||
|
||||
export * as V2Schema from "./schema"
|
||||
@@ -1,12 +1,12 @@
|
||||
import { SessionID } from "@/session/schema"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { EventV2 } from "./event"
|
||||
import { FileAttachment, Prompt } from "./session-prompt"
|
||||
import { FileAttachment, Prompt } from "@opencode-ai/core/session-prompt"
|
||||
import { Schema } from "effect"
|
||||
export { FileAttachment }
|
||||
import { ToolOutput } from "./tool-output"
|
||||
import { V2Schema } from "./schema"
|
||||
import { Modelv2 } from "./model"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { V2Schema } from "@opencode-ai/core/v2-schema"
|
||||
import { ModelV2 } from "@opencode-ai/core/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),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Schema } from "effect"
|
||||
import { Prompt } from "./session-prompt"
|
||||
import { Prompt } from "@opencode-ai/core/session-prompt"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { EventV2 } from "./event"
|
||||
import { ToolOutput } from "./tool-output"
|
||||
import { V2Schema } from "./schema"
|
||||
import { Modelv2 } from "./model"
|
||||
import { ToolOutput } from "@opencode-ai/core/tool-output"
|
||||
import { V2Schema } from "@opencode-ai/core/v2-schema"
|
||||
import { ModelV2 } from "@opencode-ai/core/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")({
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import * as Schema from "effect/Schema"
|
||||
|
||||
export class Source extends Schema.Class<Source>("Prompt.Source")({
|
||||
start: Schema.Finite,
|
||||
end: Schema.Finite,
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileAttachment extends Schema.Class<FileAttachment>("Prompt.FileAttachment")({
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
description: Schema.String.pipe(Schema.optional),
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {
|
||||
static create(input: FileAttachment) {
|
||||
return new FileAttachment({
|
||||
uri: input.uri,
|
||||
mime: input.mime,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
source: input.source,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class AgentAttachment extends Schema.Class<AgentAttachment>("Prompt.AgentAttachment")({
|
||||
name: Schema.String,
|
||||
source: Source.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export class Prompt extends Schema.Class<Prompt>("Prompt")({
|
||||
text: Schema.String,
|
||||
files: Schema.Array(FileAttachment).pipe(Schema.optional),
|
||||
agents: Schema.Array(AgentAttachment).pipe(Schema.optional),
|
||||
}) {}
|
||||
@@ -5,14 +5,15 @@ import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/s
|
||||
import * as Database from "@/storage/db"
|
||||
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect"
|
||||
import { SessionMessage } from "./session-message"
|
||||
import type { Prompt } from "./session-prompt"
|
||||
import type { Prompt } from "@opencode-ai/core/session-prompt"
|
||||
import { EventV2 } from "./event"
|
||||
import { ProjectID } from "@/project/schema"
|
||||
import { SessionEvent } from "./session-event"
|
||||
import { V2Schema } from "./schema"
|
||||
import { V2Schema } from "@opencode-ai/core/v2-schema"
|
||||
import { optionalOmitUndefined } from "@opencode-ai/core/schema"
|
||||
import { Modelv2 } from "./model"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
export const Delivery = Schema.Literals(["immediate", "deferred"]).annotate({
|
||||
identifier: "Session.Delivery",
|
||||
@@ -28,7 +29,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,
|
||||
@@ -57,7 +58,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>
|
||||
@@ -101,10 +102,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>
|
||||
}
|
||||
@@ -131,9 +132,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: ProviderV2.ID.make(row.model.providerID),
|
||||
variant: ModelV2.VariantID.make(row.model.variant ?? "default"),
|
||||
}
|
||||
: undefined,
|
||||
time: {
|
||||
@@ -144,7 +145,7 @@ export const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
const result: Interface = {
|
||||
const result = Service.of({
|
||||
create: Effect.fn("V2Session.create")(function* (_input) {
|
||||
return {} as any
|
||||
}),
|
||||
@@ -286,7 +287,7 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
subagent: Effect.fn("V2Session.subagent")(function* (input) {
|
||||
const parent = yield* result.get(input.parentID)
|
||||
const session = yield* result.create({
|
||||
const child = yield* result.create({
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
parentID: input.parentID,
|
||||
@@ -294,11 +295,11 @@ export const layer = Layer.effect(
|
||||
})
|
||||
yield* result.prompt({
|
||||
prompt: input.prompt,
|
||||
sessionID: session.id,
|
||||
sessionID: child.id,
|
||||
})
|
||||
yield* Effect.gen(function* () {
|
||||
yield* result.wait(session.id)
|
||||
const messages = yield* result.messages({ sessionID: session.id, order: "desc" })
|
||||
yield* result.wait(child.id)
|
||||
const messages = yield* result.messages({ sessionID: child.id, order: "desc" })
|
||||
const assistant = messages.find((msg) => msg.type === "assistant")
|
||||
if (!assistant) return
|
||||
const text = assistant.content.findLast((part) => part.type === "text")
|
||||
@@ -307,9 +308,9 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
compact: Effect.fn("V2Session.compact")(function* (_sessionID) {}),
|
||||
wait: Effect.fn("V2Session.wait")(function* (_sessionID) {}),
|
||||
}
|
||||
})
|
||||
|
||||
return Service.of(result)
|
||||
return result
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export * as ToolOutput from "./tool-output"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export class TextContent extends Schema.Class<TextContent>("Tool.TextContent")({
|
||||
type: Schema.Literal("text"),
|
||||
text: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class FileContent extends Schema.Class<FileContent>("Tool.FileContent")({
|
||||
type: Schema.Literal("file"),
|
||||
uri: Schema.String,
|
||||
mime: Schema.String,
|
||||
name: Schema.String.pipe(Schema.optional),
|
||||
}) {}
|
||||
|
||||
export const Content = Schema.Union([TextContent, FileContent]).pipe(Schema.toTaggedUnion("type"))
|
||||
|
||||
export const Structured = Schema.Record(Schema.String, Schema.Any)
|
||||
Reference in New Issue
Block a user