mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-09 10:59:49 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50a52bf0f1 | |||
| c74ea2166f |
@@ -5,7 +5,7 @@ import { InstallationVersion } from "@/installation/version"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Log } from "../../util"
|
import { Log } from "../../util"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { CopilotModels } from "./models"
|
import * as CopilotModels from "./models"
|
||||||
import { MessageV2 } from "@/session/message-v2"
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
|
|
||||||
const log = Log.create({ service: "plugin.copilot" })
|
const log = Log.create({ service: "plugin.copilot" })
|
||||||
|
|||||||
@@ -1,146 +1,145 @@
|
|||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import type { Model } from "@opencode-ai/sdk/v2"
|
import type { Model } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
export namespace CopilotModels {
|
export const schema = z.object({
|
||||||
export const schema = z.object({
|
data: z.array(
|
||||||
data: z.array(
|
z.object({
|
||||||
z.object({
|
model_picker_enabled: z.boolean(),
|
||||||
model_picker_enabled: z.boolean(),
|
id: z.string(),
|
||||||
id: z.string(),
|
name: z.string(),
|
||||||
name: z.string(),
|
// every version looks like: `{model.id}-YYYY-MM-DD`
|
||||||
// every version looks like: `{model.id}-YYYY-MM-DD`
|
version: z.string(),
|
||||||
version: z.string(),
|
supported_endpoints: z.array(z.string()).optional(),
|
||||||
supported_endpoints: z.array(z.string()).optional(),
|
capabilities: z.object({
|
||||||
capabilities: z.object({
|
family: z.string(),
|
||||||
family: z.string(),
|
limits: z.object({
|
||||||
limits: z.object({
|
max_context_window_tokens: z.number(),
|
||||||
max_context_window_tokens: z.number(),
|
max_output_tokens: z.number(),
|
||||||
max_output_tokens: z.number(),
|
max_prompt_tokens: z.number(),
|
||||||
max_prompt_tokens: z.number(),
|
vision: z
|
||||||
vision: z
|
.object({
|
||||||
.object({
|
max_prompt_image_size: z.number(),
|
||||||
max_prompt_image_size: z.number(),
|
max_prompt_images: z.number(),
|
||||||
max_prompt_images: z.number(),
|
supported_media_types: z.array(z.string()),
|
||||||
supported_media_types: z.array(z.string()),
|
})
|
||||||
})
|
.optional(),
|
||||||
.optional(),
|
}),
|
||||||
}),
|
supports: z.object({
|
||||||
supports: z.object({
|
adaptive_thinking: z.boolean().optional(),
|
||||||
adaptive_thinking: z.boolean().optional(),
|
max_thinking_budget: z.number().optional(),
|
||||||
max_thinking_budget: z.number().optional(),
|
min_thinking_budget: z.number().optional(),
|
||||||
min_thinking_budget: z.number().optional(),
|
reasoning_effort: z.array(z.string()).optional(),
|
||||||
reasoning_effort: z.array(z.string()).optional(),
|
streaming: z.boolean(),
|
||||||
streaming: z.boolean(),
|
structured_outputs: z.boolean().optional(),
|
||||||
structured_outputs: z.boolean().optional(),
|
tool_calls: z.boolean(),
|
||||||
tool_calls: z.boolean(),
|
vision: z.boolean().optional(),
|
||||||
vision: z.boolean().optional(),
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
}),
|
||||||
})
|
),
|
||||||
|
})
|
||||||
|
|
||||||
type Item = z.infer<typeof schema>["data"][number]
|
type Item = z.infer<typeof schema>["data"][number]
|
||||||
|
|
||||||
function build(key: string, remote: Item, url: string, prev?: Model): Model {
|
function build(key: string, remote: Item, url: string, prev?: Model): Model {
|
||||||
const reasoning =
|
const reasoning =
|
||||||
!!remote.capabilities.supports.adaptive_thinking ||
|
!!remote.capabilities.supports.adaptive_thinking ||
|
||||||
!!remote.capabilities.supports.reasoning_effort?.length ||
|
!!remote.capabilities.supports.reasoning_effort?.length ||
|
||||||
remote.capabilities.supports.max_thinking_budget !== undefined ||
|
remote.capabilities.supports.max_thinking_budget !== undefined ||
|
||||||
remote.capabilities.supports.min_thinking_budget !== undefined
|
remote.capabilities.supports.min_thinking_budget !== undefined
|
||||||
const image =
|
const image =
|
||||||
(remote.capabilities.supports.vision ?? false) ||
|
(remote.capabilities.supports.vision ?? false) ||
|
||||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||||
|
|
||||||
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
|
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: key,
|
id: key,
|
||||||
providerID: "github-copilot",
|
providerID: "github-copilot",
|
||||||
api: {
|
api: {
|
||||||
id: remote.id,
|
id: remote.id,
|
||||||
url: isMsgApi ? `${url}/v1` : url,
|
url: isMsgApi ? `${url}/v1` : url,
|
||||||
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
||||||
|
},
|
||||||
|
// API response wins
|
||||||
|
status: "active",
|
||||||
|
limit: {
|
||||||
|
context: remote.capabilities.limits.max_context_window_tokens,
|
||||||
|
input: remote.capabilities.limits.max_prompt_tokens,
|
||||||
|
output: remote.capabilities.limits.max_output_tokens,
|
||||||
|
},
|
||||||
|
capabilities: {
|
||||||
|
temperature: prev?.capabilities.temperature ?? true,
|
||||||
|
reasoning: prev?.capabilities.reasoning ?? reasoning,
|
||||||
|
attachment: prev?.capabilities.attachment ?? true,
|
||||||
|
toolcall: remote.capabilities.supports.tool_calls,
|
||||||
|
input: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
},
|
},
|
||||||
// API response wins
|
output: {
|
||||||
status: "active",
|
text: true,
|
||||||
limit: {
|
audio: false,
|
||||||
context: remote.capabilities.limits.max_context_window_tokens,
|
image: false,
|
||||||
input: remote.capabilities.limits.max_prompt_tokens,
|
video: false,
|
||||||
output: remote.capabilities.limits.max_output_tokens,
|
pdf: false,
|
||||||
},
|
},
|
||||||
capabilities: {
|
interleaved: false,
|
||||||
temperature: prev?.capabilities.temperature ?? true,
|
},
|
||||||
reasoning: prev?.capabilities.reasoning ?? reasoning,
|
// existing wins
|
||||||
attachment: prev?.capabilities.attachment ?? true,
|
family: prev?.family ?? remote.capabilities.family,
|
||||||
toolcall: remote.capabilities.supports.tool_calls,
|
name: prev?.name ?? remote.name,
|
||||||
input: {
|
cost: {
|
||||||
text: true,
|
input: 0,
|
||||||
audio: false,
|
output: 0,
|
||||||
image,
|
cache: { read: 0, write: 0 },
|
||||||
video: false,
|
},
|
||||||
pdf: false,
|
options: prev?.options ?? {},
|
||||||
},
|
headers: prev?.headers ?? {},
|
||||||
output: {
|
release_date:
|
||||||
text: true,
|
prev?.release_date ??
|
||||||
audio: false,
|
(remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version),
|
||||||
image: false,
|
variants: prev?.variants ?? {},
|
||||||
video: false,
|
|
||||||
pdf: false,
|
|
||||||
},
|
|
||||||
interleaved: false,
|
|
||||||
},
|
|
||||||
// existing wins
|
|
||||||
family: prev?.family ?? remote.capabilities.family,
|
|
||||||
name: prev?.name ?? remote.name,
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cache: { read: 0, write: 0 },
|
|
||||||
},
|
|
||||||
options: prev?.options ?? {},
|
|
||||||
headers: prev?.headers ?? {},
|
|
||||||
release_date:
|
|
||||||
prev?.release_date ??
|
|
||||||
(remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version),
|
|
||||||
variants: prev?.variants ?? {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function get(
|
|
||||||
baseURL: string,
|
|
||||||
headers: HeadersInit = {},
|
|
||||||
existing: Record<string, Model> = {},
|
|
||||||
): Promise<Record<string, Model>> {
|
|
||||||
const data = await fetch(`${baseURL}/models`, {
|
|
||||||
headers,
|
|
||||||
signal: AbortSignal.timeout(5_000),
|
|
||||||
}).then(async (res) => {
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to fetch models: ${res.status}`)
|
|
||||||
}
|
|
||||||
return schema.parse(await res.json())
|
|
||||||
})
|
|
||||||
|
|
||||||
const result = { ...existing }
|
|
||||||
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
|
|
||||||
|
|
||||||
// prune existing models whose api.id isn't in the endpoint response
|
|
||||||
for (const [key, model] of Object.entries(result)) {
|
|
||||||
const m = remote.get(model.api.id)
|
|
||||||
if (!m) {
|
|
||||||
delete result[key]
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
result[key] = build(key, m, baseURL, model)
|
|
||||||
}
|
|
||||||
|
|
||||||
// add new endpoint models not already keyed in result
|
|
||||||
for (const [id, m] of remote) {
|
|
||||||
if (id in result) continue
|
|
||||||
result[id] = build(id, m, baseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function get(
|
||||||
|
baseURL: string,
|
||||||
|
headers: HeadersInit = {},
|
||||||
|
existing: Record<string, Model> = {},
|
||||||
|
): Promise<Record<string, Model>> {
|
||||||
|
const data = await fetch(`${baseURL}/models`, {
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(5_000),
|
||||||
|
}).then(async (res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to fetch models: ${res.status}`)
|
||||||
|
}
|
||||||
|
return schema.parse(await res.json())
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = { ...existing }
|
||||||
|
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
|
||||||
|
|
||||||
|
// prune existing models whose api.id isn't in the endpoint response
|
||||||
|
for (const [key, model] of Object.entries(result)) {
|
||||||
|
const m = remote.get(model.api.id)
|
||||||
|
if (!m) {
|
||||||
|
delete result[key]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[key] = build(key, m, baseURL, model)
|
||||||
|
}
|
||||||
|
|
||||||
|
// add new endpoint models not already keyed in result
|
||||||
|
for (const [id, m] of remote) {
|
||||||
|
if (id in result) continue
|
||||||
|
result[id] = build(id, m, baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
export * as CopilotModels from "./models"
|
||||||
|
|||||||
@@ -11,164 +11,163 @@ import {
|
|||||||
import { ConfigPlugin } from "@/config/plugin"
|
import { ConfigPlugin } from "@/config/plugin"
|
||||||
import { InstallationVersion } from "@/installation/version"
|
import { InstallationVersion } from "@/installation/version"
|
||||||
|
|
||||||
export namespace PluginLoader {
|
export type Plan = {
|
||||||
export type Plan = {
|
spec: string
|
||||||
spec: string
|
options: ConfigPlugin.Options | undefined
|
||||||
options: ConfigPlugin.Options | undefined
|
deprecated: boolean
|
||||||
deprecated: boolean
|
|
||||||
}
|
|
||||||
export type Resolved = Plan & {
|
|
||||||
source: PluginSource
|
|
||||||
target: string
|
|
||||||
entry: string
|
|
||||||
pkg?: PluginPackage
|
|
||||||
}
|
|
||||||
export type Missing = Plan & {
|
|
||||||
source: PluginSource
|
|
||||||
target: string
|
|
||||||
pkg?: PluginPackage
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
export type Loaded = Resolved & {
|
|
||||||
mod: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
|
|
||||||
type Report = {
|
|
||||||
start?: (candidate: Candidate, retry: boolean) => void
|
|
||||||
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
|
|
||||||
error?: (
|
|
||||||
candidate: Candidate,
|
|
||||||
retry: boolean,
|
|
||||||
stage: "install" | "entry" | "compatibility" | "load",
|
|
||||||
error: unknown,
|
|
||||||
resolved?: Resolved,
|
|
||||||
) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function plan(item: ConfigPlugin.Spec): Plan {
|
|
||||||
const spec = ConfigPlugin.pluginSpecifier(item)
|
|
||||||
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolve(
|
|
||||||
plan: Plan,
|
|
||||||
kind: PluginKind,
|
|
||||||
): Promise<
|
|
||||||
| { ok: true; value: Resolved }
|
|
||||||
| { ok: false; stage: "missing"; value: Missing }
|
|
||||||
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
|
||||||
> {
|
|
||||||
let target = ""
|
|
||||||
try {
|
|
||||||
target = await resolvePluginTarget(plan.spec)
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, stage: "install", error }
|
|
||||||
}
|
|
||||||
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
|
|
||||||
|
|
||||||
let base
|
|
||||||
try {
|
|
||||||
base = await createPluginEntry(plan.spec, target, kind)
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, stage: "entry", error }
|
|
||||||
}
|
|
||||||
if (!base.entry)
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
stage: "missing",
|
|
||||||
value: {
|
|
||||||
...plan,
|
|
||||||
source: base.source,
|
|
||||||
target: base.target,
|
|
||||||
pkg: base.pkg,
|
|
||||||
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if (base.source === "npm") {
|
|
||||||
try {
|
|
||||||
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, stage: "compatibility", error }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
|
|
||||||
let mod
|
|
||||||
try {
|
|
||||||
mod = await import(row.entry)
|
|
||||||
} catch (error) {
|
|
||||||
return { ok: false, error }
|
|
||||||
}
|
|
||||||
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
|
|
||||||
return { ok: true, value: { ...row, mod } }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function attempt<R>(
|
|
||||||
candidate: Candidate,
|
|
||||||
kind: PluginKind,
|
|
||||||
retry: boolean,
|
|
||||||
finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
|
|
||||||
missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
|
|
||||||
report: Report | undefined,
|
|
||||||
): Promise<R | undefined> {
|
|
||||||
const plan = candidate.plan
|
|
||||||
if (plan.deprecated) return
|
|
||||||
report?.start?.(candidate, retry)
|
|
||||||
const resolved = await resolve(plan, kind)
|
|
||||||
if (!resolved.ok) {
|
|
||||||
if (resolved.stage === "missing") {
|
|
||||||
if (missing) {
|
|
||||||
const value = await missing(resolved.value, candidate.origin, retry)
|
|
||||||
if (value !== undefined) return value
|
|
||||||
}
|
|
||||||
report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
report?.error?.(candidate, retry, resolved.stage, resolved.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const loaded = await load(resolved.value)
|
|
||||||
if (!loaded.ok) {
|
|
||||||
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!finish) return loaded.value as R
|
|
||||||
return finish(loaded.value, candidate.origin, retry)
|
|
||||||
}
|
|
||||||
|
|
||||||
type Input<R> = {
|
|
||||||
items: ConfigPlugin.Origin[]
|
|
||||||
kind: PluginKind
|
|
||||||
wait?: () => Promise<void>
|
|
||||||
finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
|
|
||||||
missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
|
|
||||||
report?: Report
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
|
|
||||||
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
|
|
||||||
const list: Array<Promise<R | undefined>> = []
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
|
|
||||||
}
|
|
||||||
const out = await Promise.all(list)
|
|
||||||
if (input.wait) {
|
|
||||||
let deps: Promise<void> | undefined
|
|
||||||
for (let i = 0; i < candidates.length; i++) {
|
|
||||||
if (out[i] !== undefined) continue
|
|
||||||
const candidate = candidates[i]
|
|
||||||
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
|
|
||||||
deps ??= input.wait()
|
|
||||||
await deps
|
|
||||||
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const ready: R[] = []
|
|
||||||
for (const item of out) if (item !== undefined) ready.push(item)
|
|
||||||
return ready
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
export type Resolved = Plan & {
|
||||||
|
source: PluginSource
|
||||||
|
target: string
|
||||||
|
entry: string
|
||||||
|
pkg?: PluginPackage
|
||||||
|
}
|
||||||
|
export type Missing = Plan & {
|
||||||
|
source: PluginSource
|
||||||
|
target: string
|
||||||
|
pkg?: PluginPackage
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
export type Loaded = Resolved & {
|
||||||
|
mod: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Candidate = { origin: ConfigPlugin.Origin; plan: Plan }
|
||||||
|
type Report = {
|
||||||
|
start?: (candidate: Candidate, retry: boolean) => void
|
||||||
|
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
|
||||||
|
error?: (
|
||||||
|
candidate: Candidate,
|
||||||
|
retry: boolean,
|
||||||
|
stage: "install" | "entry" | "compatibility" | "load",
|
||||||
|
error: unknown,
|
||||||
|
resolved?: Resolved,
|
||||||
|
) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function plan(item: ConfigPlugin.Spec): Plan {
|
||||||
|
const spec = ConfigPlugin.pluginSpecifier(item)
|
||||||
|
return { spec, options: ConfigPlugin.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolve(
|
||||||
|
plan: Plan,
|
||||||
|
kind: PluginKind,
|
||||||
|
): Promise<
|
||||||
|
| { ok: true; value: Resolved }
|
||||||
|
| { ok: false; stage: "missing"; value: Missing }
|
||||||
|
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
||||||
|
> {
|
||||||
|
let target = ""
|
||||||
|
try {
|
||||||
|
target = await resolvePluginTarget(plan.spec)
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, stage: "install", error }
|
||||||
|
}
|
||||||
|
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
|
||||||
|
|
||||||
|
let base
|
||||||
|
try {
|
||||||
|
base = await createPluginEntry(plan.spec, target, kind)
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, stage: "entry", error }
|
||||||
|
}
|
||||||
|
if (!base.entry)
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
stage: "missing",
|
||||||
|
value: {
|
||||||
|
...plan,
|
||||||
|
source: base.source,
|
||||||
|
target: base.target,
|
||||||
|
pkg: base.pkg,
|
||||||
|
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (base.source === "npm") {
|
||||||
|
try {
|
||||||
|
await checkPluginCompatibility(base.target, InstallationVersion, base.pkg)
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, stage: "compatibility", error }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
|
||||||
|
let mod
|
||||||
|
try {
|
||||||
|
mod = await import(row.entry)
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error }
|
||||||
|
}
|
||||||
|
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
|
||||||
|
return { ok: true, value: { ...row, mod } }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attempt<R>(
|
||||||
|
candidate: Candidate,
|
||||||
|
kind: PluginKind,
|
||||||
|
retry: boolean,
|
||||||
|
finish: ((load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
|
||||||
|
missing: ((value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>) | undefined,
|
||||||
|
report: Report | undefined,
|
||||||
|
): Promise<R | undefined> {
|
||||||
|
const plan = candidate.plan
|
||||||
|
if (plan.deprecated) return
|
||||||
|
report?.start?.(candidate, retry)
|
||||||
|
const resolved = await resolve(plan, kind)
|
||||||
|
if (!resolved.ok) {
|
||||||
|
if (resolved.stage === "missing") {
|
||||||
|
if (missing) {
|
||||||
|
const value = await missing(resolved.value, candidate.origin, retry)
|
||||||
|
if (value !== undefined) return value
|
||||||
|
}
|
||||||
|
report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
report?.error?.(candidate, retry, resolved.stage, resolved.error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const loaded = await load(resolved.value)
|
||||||
|
if (!loaded.ok) {
|
||||||
|
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!finish) return loaded.value as R
|
||||||
|
return finish(loaded.value, candidate.origin, retry)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Input<R> = {
|
||||||
|
items: ConfigPlugin.Origin[]
|
||||||
|
kind: PluginKind
|
||||||
|
wait?: () => Promise<void>
|
||||||
|
finish?: (load: Loaded, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
|
||||||
|
missing?: (value: Missing, origin: ConfigPlugin.Origin, retry: boolean) => Promise<R | undefined>
|
||||||
|
report?: Report
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
|
||||||
|
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
|
||||||
|
const list: Array<Promise<R | undefined>> = []
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
|
||||||
|
}
|
||||||
|
const out = await Promise.all(list)
|
||||||
|
if (input.wait) {
|
||||||
|
let deps: Promise<void> | undefined
|
||||||
|
for (let i = 0; i < candidates.length; i++) {
|
||||||
|
if (out[i] !== undefined) continue
|
||||||
|
const candidate = candidates[i]
|
||||||
|
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
|
||||||
|
deps ??= input.wait()
|
||||||
|
await deps
|
||||||
|
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ready: R[] = []
|
||||||
|
for (const item of out) if (item !== undefined) ready.push(item)
|
||||||
|
return ready
|
||||||
|
}
|
||||||
|
export * as PluginLoader from "./loader"
|
||||||
|
|||||||
@@ -8,181 +8,180 @@ import { Flock } from "@opencode-ai/shared/util/flock"
|
|||||||
|
|
||||||
import { parsePluginSpecifier, pluginSource } from "./shared"
|
import { parsePluginSpecifier, pluginSource } from "./shared"
|
||||||
|
|
||||||
export namespace PluginMeta {
|
type Source = "file" | "npm"
|
||||||
type Source = "file" | "npm"
|
|
||||||
|
|
||||||
export type Theme = {
|
export type Theme = {
|
||||||
src: string
|
src: string
|
||||||
dest: string
|
dest: string
|
||||||
mtime?: number
|
mtime?: number
|
||||||
size?: number
|
size?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Entry = {
|
export type Entry = {
|
||||||
id: string
|
id: string
|
||||||
source: Source
|
source: Source
|
||||||
spec: string
|
spec: string
|
||||||
target: string
|
target: string
|
||||||
requested?: string
|
requested?: string
|
||||||
version?: string
|
version?: string
|
||||||
modified?: number
|
modified?: number
|
||||||
first_time: number
|
first_time: number
|
||||||
last_time: number
|
last_time: number
|
||||||
time_changed: number
|
time_changed: number
|
||||||
load_count: number
|
load_count: number
|
||||||
fingerprint: string
|
fingerprint: string
|
||||||
themes?: Record<string, Theme>
|
themes?: Record<string, Theme>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type State = "first" | "updated" | "same"
|
export type State = "first" | "updated" | "same"
|
||||||
|
|
||||||
export type Touch = {
|
export type Touch = {
|
||||||
spec: string
|
spec: string
|
||||||
target: string
|
target: string
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Store = Record<string, Entry>
|
type Store = Record<string, Entry>
|
||||||
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
|
type Core = Omit<Entry, "first_time" | "last_time" | "time_changed" | "load_count" | "fingerprint" | "themes">
|
||||||
type Row = Touch & { core: Core }
|
type Row = Touch & { core: Core }
|
||||||
|
|
||||||
function storePath() {
|
function storePath() {
|
||||||
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
|
return Flag.OPENCODE_PLUGIN_META_FILE ?? path.join(Global.Path.state, "plugin-meta.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
function lock(file: string) {
|
function lock(file: string) {
|
||||||
return `plugin-meta:${file}`
|
return `plugin-meta:${file}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function fileTarget(spec: string, target: string) {
|
function fileTarget(spec: string, target: string) {
|
||||||
if (spec.startsWith("file://")) return fileURLToPath(spec)
|
if (spec.startsWith("file://")) return fileURLToPath(spec)
|
||||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
async function modifiedAt(file: string) {
|
async function modifiedAt(file: string) {
|
||||||
const stat = await Filesystem.statAsync(file)
|
const stat = await Filesystem.statAsync(file)
|
||||||
if (!stat) return
|
if (!stat) return
|
||||||
const mtime = stat.mtimeMs
|
const mtime = stat.mtimeMs
|
||||||
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
|
return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvedTarget(target: string) {
|
function resolvedTarget(target: string) {
|
||||||
if (target.startsWith("file://")) return fileURLToPath(target)
|
if (target.startsWith("file://")) return fileURLToPath(target)
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
|
|
||||||
async function npmVersion(target: string) {
|
async function npmVersion(target: string) {
|
||||||
const resolved = resolvedTarget(target)
|
const resolved = resolvedTarget(target)
|
||||||
const stat = await Filesystem.statAsync(resolved)
|
const stat = await Filesystem.statAsync(resolved)
|
||||||
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
|
const dir = stat?.isDirectory() ? resolved : path.dirname(resolved)
|
||||||
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
|
return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json"))
|
||||||
.then((item) => item.version)
|
.then((item) => item.version)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function entryCore(item: Touch): Promise<Core> {
|
|
||||||
const spec = item.spec
|
|
||||||
const target = item.target
|
|
||||||
const source = pluginSource(spec)
|
|
||||||
if (source === "file") {
|
|
||||||
const file = fileTarget(spec, target)
|
|
||||||
return {
|
|
||||||
id: item.id,
|
|
||||||
source,
|
|
||||||
spec,
|
|
||||||
target,
|
|
||||||
modified: file ? await modifiedAt(file) : undefined,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async function entryCore(item: Touch): Promise<Core> {
|
||||||
|
const spec = item.spec
|
||||||
|
const target = item.target
|
||||||
|
const source = pluginSource(spec)
|
||||||
|
if (source === "file") {
|
||||||
|
const file = fileTarget(spec, target)
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
source,
|
source,
|
||||||
spec,
|
spec,
|
||||||
target,
|
target,
|
||||||
requested: parsePluginSpecifier(spec).version,
|
modified: file ? await modifiedAt(file) : undefined,
|
||||||
version: await npmVersion(target),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fingerprint(value: Core) {
|
return {
|
||||||
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
|
id: item.id,
|
||||||
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
|
source,
|
||||||
}
|
spec,
|
||||||
|
target,
|
||||||
async function read(file: string): Promise<Store> {
|
requested: parsePluginSpecifier(spec).version,
|
||||||
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
|
version: await npmVersion(target),
|
||||||
}
|
|
||||||
|
|
||||||
async function row(item: Touch): Promise<Row> {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
core: await entryCore(item),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
|
|
||||||
const entry: Entry = {
|
|
||||||
...core,
|
|
||||||
first_time: prev?.first_time ?? now,
|
|
||||||
last_time: now,
|
|
||||||
time_changed: prev?.time_changed ?? now,
|
|
||||||
load_count: (prev?.load_count ?? 0) + 1,
|
|
||||||
fingerprint: fingerprint(core),
|
|
||||||
themes: prev?.themes,
|
|
||||||
}
|
|
||||||
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
|
|
||||||
if (state === "updated") entry.time_changed = now
|
|
||||||
return {
|
|
||||||
state,
|
|
||||||
entry,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
|
|
||||||
if (!items.length) return []
|
|
||||||
const file = storePath()
|
|
||||||
const rows = await Promise.all(items.map((item) => row(item)))
|
|
||||||
|
|
||||||
return Flock.withLock(lock(file), async () => {
|
|
||||||
const store = await read(file)
|
|
||||||
const now = Date.now()
|
|
||||||
const out: Array<{ state: State; entry: Entry }> = []
|
|
||||||
for (const item of rows) {
|
|
||||||
const hit = next(store[item.id], item.core, now)
|
|
||||||
store[item.id] = hit.entry
|
|
||||||
out.push(hit)
|
|
||||||
}
|
|
||||||
await Filesystem.writeJson(file, store)
|
|
||||||
return out
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
|
|
||||||
return touchMany([{ spec, target, id }]).then((item) => {
|
|
||||||
const hit = item[0]
|
|
||||||
if (hit) return hit
|
|
||||||
throw new Error("Failed to touch plugin metadata.")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
|
|
||||||
const file = storePath()
|
|
||||||
await Flock.withLock(lock(file), async () => {
|
|
||||||
const store = await read(file)
|
|
||||||
const entry = store[id]
|
|
||||||
if (!entry) return
|
|
||||||
entry.themes = {
|
|
||||||
...entry.themes,
|
|
||||||
[name]: theme,
|
|
||||||
}
|
|
||||||
await Filesystem.writeJson(file, store)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list(): Promise<Store> {
|
|
||||||
const file = storePath()
|
|
||||||
return Flock.withLock(lock(file), async () => read(file))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fingerprint(value: Core) {
|
||||||
|
if (value.source === "file") return [value.target, value.modified ?? ""].join("|")
|
||||||
|
return [value.target, value.requested ?? "", value.version ?? ""].join("|")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read(file: string): Promise<Store> {
|
||||||
|
return Filesystem.readJson<Store>(file).catch(() => ({}) as Store)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function row(item: Touch): Promise<Row> {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
core: await entryCore(item),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function next(prev: Entry | undefined, core: Core, now: number): { state: State; entry: Entry } {
|
||||||
|
const entry: Entry = {
|
||||||
|
...core,
|
||||||
|
first_time: prev?.first_time ?? now,
|
||||||
|
last_time: now,
|
||||||
|
time_changed: prev?.time_changed ?? now,
|
||||||
|
load_count: (prev?.load_count ?? 0) + 1,
|
||||||
|
fingerprint: fingerprint(core),
|
||||||
|
themes: prev?.themes,
|
||||||
|
}
|
||||||
|
const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated"
|
||||||
|
if (state === "updated") entry.time_changed = now
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
entry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touchMany(items: Touch[]): Promise<Array<{ state: State; entry: Entry }>> {
|
||||||
|
if (!items.length) return []
|
||||||
|
const file = storePath()
|
||||||
|
const rows = await Promise.all(items.map((item) => row(item)))
|
||||||
|
|
||||||
|
return Flock.withLock(lock(file), async () => {
|
||||||
|
const store = await read(file)
|
||||||
|
const now = Date.now()
|
||||||
|
const out: Array<{ state: State; entry: Entry }> = []
|
||||||
|
for (const item of rows) {
|
||||||
|
const hit = next(store[item.id], item.core, now)
|
||||||
|
store[item.id] = hit.entry
|
||||||
|
out.push(hit)
|
||||||
|
}
|
||||||
|
await Filesystem.writeJson(file, store)
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function touch(spec: string, target: string, id: string): Promise<{ state: State; entry: Entry }> {
|
||||||
|
return touchMany([{ spec, target, id }]).then((item) => {
|
||||||
|
const hit = item[0]
|
||||||
|
if (hit) return hit
|
||||||
|
throw new Error("Failed to touch plugin metadata.")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setTheme(id: string, name: string, theme: Theme): Promise<void> {
|
||||||
|
const file = storePath()
|
||||||
|
await Flock.withLock(lock(file), async () => {
|
||||||
|
const store = await read(file)
|
||||||
|
const entry = store[id]
|
||||||
|
if (!entry) return
|
||||||
|
entry.themes = {
|
||||||
|
...entry.themes,
|
||||||
|
[name]: theme,
|
||||||
|
}
|
||||||
|
await Filesystem.writeJson(file, store)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function list(): Promise<Store> {
|
||||||
|
const file = storePath()
|
||||||
|
return Flock.withLock(lock(file), async () => read(file))
|
||||||
|
}
|
||||||
|
export * as PluginMeta from "./meta"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { Effect, Layer, Context, Stream } from "effect"
|
|||||||
import { EffectBridge } from "@/effect"
|
import { EffectBridge } from "@/effect"
|
||||||
import { InstanceState } from "@/effect"
|
import { InstanceState } from "@/effect"
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
import { PluginLoader } from "./loader"
|
import * as PluginLoader from "./loader"
|
||||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||||
import { registerAdaptor } from "@/control-plane/adaptors"
|
import { registerAdaptor } from "@/control-plane/adaptors"
|
||||||
import type { WorkspaceAdaptor } from "@/control-plane/types"
|
import type { WorkspaceAdaptor } from "@/control-plane/types"
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ if (typeof msg.id !== "string") throw new Error("Invalid worker payload")
|
|||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = msg.file
|
process.env.OPENCODE_PLUGIN_META_FILE = msg.file
|
||||||
|
|
||||||
const { PluginMeta } = await import("../../src/plugin/meta")
|
const PluginMeta = await import("../../src/plugin/meta")
|
||||||
|
|
||||||
await PluginMeta.touch(msg.spec, msg.target, msg.id)
|
await PluginMeta.touch(msg.spec, msg.target, msg.id)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, expect, mock, test } from "bun:test"
|
import { afterEach, expect, mock, test } from "bun:test"
|
||||||
import { CopilotModels } from "@/plugin/github-copilot/models"
|
import { CopilotModels } from "../../src/plugin/github-copilot/models"
|
||||||
import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot"
|
import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot"
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
|||||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||||
|
|
||||||
const { Plugin } = await import("../../src/plugin/index")
|
const { Plugin } = await import("../../src/plugin/index")
|
||||||
const { PluginLoader } = await import("../../src/plugin/loader")
|
const PluginLoader = await import("../../src/plugin/loader")
|
||||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||||
const { Instance } = await import("../../src/project/instance")
|
const { Instance } = await import("../../src/project/instance")
|
||||||
const { Npm } = await import("../../src/npm")
|
const { Npm } = await import("../../src/npm")
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { tmpdir } from "../fixture/fixture"
|
|||||||
import { Process } from "../../src/util"
|
import { Process } from "../../src/util"
|
||||||
import { Filesystem } from "../../src/util"
|
import { Filesystem } from "../../src/util"
|
||||||
|
|
||||||
const { PluginMeta } = await import("../../src/plugin/meta")
|
const PluginMeta = await import("../../src/plugin/meta")
|
||||||
const root = path.join(import.meta.dir, "../..")
|
const root = path.join(import.meta.dir, "../..")
|
||||||
const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")
|
const worker = path.join(import.meta.dir, "../fixture/plugin-meta-worker.ts")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user