Compare commits

..
305 changed files with 9239 additions and 13704 deletions
+2 -2
View File
@@ -255,7 +255,6 @@
"name": "@opencode-ai/desktop",
"version": "1.15.6",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
@@ -430,12 +429,14 @@
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@effect/sql-sqlite-bun": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/llm": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
@@ -717,7 +718,6 @@
],
"patchedDependencies": {
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-hIarzU3QNIvkwpfnearfsGaBMCSdovkOWAuvX+EBQI8=",
"aarch64-linux": "sha256-qu+86RNsBZhqzE3CEyg4XHD/3Czc/BqHhY3O1vyogtY=",
"aarch64-darwin": "sha256-7sXZdPh/R4fQBBMq36YdpQE6G2ELMZd08BqMhHbhwv4=",
"x86_64-darwin": "sha256-g5QWpX33R143sNR1G9m7UsSA3jygFyJjT7Kjyjo6sCc="
"x86_64-linux": "sha256-1KQFagCMMfSdZJLPAr0b17V66Z2ITcaQis4Pa2jC1hE=",
"aarch64-linux": "sha256-DWhmkYpa9ArqzfPdmmNFkaiOw5+DllEBHESU54T/aQA=",
"aarch64-darwin": "sha256-egfIey1y2wVbvxueiI4S9IPl6IvfVpJvvj3h4B2nkxA=",
"x86_64-darwin": "sha256-22Rezk0MiDIeT4qeeT/iosDaEX1l2kn6B7/eNphT678="
}
}
+1 -2
View File
@@ -139,7 +139,6 @@
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"@ai-sdk/xai@3.0.82": "patches/@ai-sdk%2Fxai@3.0.82.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
}
}
+17 -33
View File
@@ -1,5 +1,4 @@
import { expect, test, type Page } from "@playwright/test"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { fixture, pageMessages } from "./session-timeline.fixture"
import { trackPageErrors, expectNoSmokeErrors } from "../utils/errors"
import { mockOpenCodeServer } from "../utils/mock-server"
@@ -38,12 +37,12 @@ test.describe("smoke: session timeline", () => {
project: fixture.project,
pageMessages,
})
await configureSmokePage(page, fixture.directory)
await configureSmokePage(page)
await selectHomeProject(page, fixture.project.name)
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page)
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
await openProject(page, "SmokeProject")
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
await expectSessionReady(page, "smoke-project")
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
const expectedPartIDs = fixture.expected.targetPartIDs
const expectedMessageIDs = fixture.expected.targetMessageIDs
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
@@ -51,7 +50,7 @@ test.describe("smoke: session timeline", () => {
})
})
async function configureSmokePage(page: Page, directory: string) {
async function configureSmokePage(page: Page) {
await page.addInitScript(() => {
localStorage.setItem(
"settings.v3",
@@ -64,23 +63,7 @@ async function configureSmokePage(page: Page, directory: string) {
},
}),
)
})
await page.addInitScript((directory) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: {
local: [{ worktree: directory, expanded: true }],
},
lastProject: {
local: directory,
},
}),
)
}, directory)
await page.addInitScript(() => {
const smoke = window as SmokeWindow
smoke.__timelineSmokeErrorToasts = []
smoke.__timelineSmokeForbiddenText = []
@@ -409,20 +392,21 @@ function expectCompleteScroll(
expect(expectedPartIDs.length).toBe(331)
}
async function selectHomeProject(page: Page, projectName: string) {
async function openProject(page: Page, projectName: string) {
await page.goto("/")
await page
.locator('[data-component="home-project-row"]')
.filter({ hasText: new RegExp(projectName, "i") })
.click()
await expect(page).toHaveURL(/\/$/)
await page.getByRole("button", { name: new RegExp(projectName, "i") }).click()
}
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
// Use evaluate to click to avoid strict visibility/animation issues during rapid e2e navigation
await page
.locator(`a[href*="${sessionId}"]`)
.first()
.evaluate((el) => (el as HTMLElement).click())
await expect(page.getByRole("heading", { name: expectedTitle })).toBeVisible()
}
async function expectSessionReady(page: Page) {
await expect(page.getByRole("textbox", { name: /Ask anything/i })).toBeVisible()
async function expectSessionReady(page: Page, projectName: string) {
await expect(page.getByText(projectName).first()).toBeVisible()
await expect(page.getByText("Ask anything...")).toBeVisible()
}
-1
View File
@@ -78,7 +78,6 @@ declare global {
}
api?: {
setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise<void>
exportDebugLogs?: () => Promise<string>
}
}
}
@@ -12,7 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/core/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
+280 -559
View File
@@ -1,17 +1,6 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import {
createEffect,
on,
Component,
Show,
onCleanup,
createMemo,
createSignal,
createResource,
Switch,
Match,
} from "solid-js"
import { createEffect, on, Component, Show, onCleanup, createMemo, createSignal, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocal } from "@/context/local"
import { selectionFromLines, type SelectedLineRange, useFile } from "@/context/file"
@@ -68,14 +57,11 @@ import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { useQueries } from "@tanstack/solid-query"
import { useQueryOptions } from "@/context/global-sync"
import { pathKey } from "@/utils/path-key"
import { getFilename } from "@opencode-ai/core/util/path"
interface PromptInputProps {
class?: string
variant?: "dock" | "new-session"
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeChange?: (worktree: string) => void
onNewSessionWorktreeReset?: () => void
edit?: { id: string; prompt: Prompt; context: FollowupDraft["context"] }
onEditLoaded?: () => void
@@ -113,9 +99,6 @@ const EXAMPLES = [
"prompt.example.25",
] as const
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK()
const queryOptions = useQueryOptions()
@@ -1072,21 +1055,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
readClipboardImage: platform.readClipboardImage,
})
const fileAttachmentInput = () => (
<input
ref={(el) => (fileInputRef = el)}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>
)
const variants = createMemo(() => ["default", ...local.model.variant.list()])
const accepting = createMemo(() => {
const id = params.id
@@ -1298,99 +1266,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
(p) => p,
)
const designPlaceholder = () => {
if (store.mode === "shell") return placeholder()
return "Ask anything, / for commands, @ for context..."
}
const modelControl = () => (
<Show when={!providersLoading()}>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class:
"min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-4 text-v2-text-text-faint group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">{local.model.current()?.name ?? language.t("dialog.model.select.title")}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</Show>
)
const newSession = () => props.variant === "new-session"
const worktrees = createMemo(() => [MAIN_WORKTREE, ...(sync.project?.sandboxes ?? []), CREATE_WORKTREE])
const currentWorktree = createMemo(() => {
if (worktrees().includes(props.newSessionWorktree ?? MAIN_WORKTREE))
return props.newSessionWorktree ?? MAIN_WORKTREE
return MAIN_WORKTREE
})
const worktreeLabel = (value: string) => {
if (value === MAIN_WORKTREE) return MAIN_WORKTREE
if (value === CREATE_WORKTREE) return language.t("session.new.worktree.create")
return getFilename(value)
}
const USE_V2_INPUT = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
return (
<div class="relative size-full flex flex-col gap-0">
<div class="relative size-full _max-h-[320px] flex flex-col gap-0">
{(promptReady(), null)}
<PromptPopover
popover={store.popover}
@@ -1407,138 +1284,121 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
commandKeybind={command.keybind}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<Switch>
<Match when={USE_V2_INPUT}>
<DockShellForm
data-component={newSession() ? "session-new-composer" : "session-composer"}
onSubmit={handleSubmit}
classList={{
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
<DockShellForm
onSubmit={handleSubmit}
classList={{
"group/prompt-input": true,
"focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<PromptDragOverlay
type={store.draggingType}
label={language.t(store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label")}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
<div
class="relative"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
return
}
editorRef?.focus()
}}
>
<div
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
ref={(el) => (scrollRef = el)}
style={{ "scroll-padding-bottom": space }}
>
<PromptDragOverlay
type={store.draggingType}
label={language.t(
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
)}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
style={{ "padding-bottom": space }}
/>
<div
class="relative min-h-[52px]"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) return
editorRef?.focus()
}}
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
>
<div class="relative max-h-[180px] overflow-y-auto no-scrollbar" ref={(el) => (scrollRef = el)}>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={designPlaceholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"min-h-[52px] w-full px-4 pt-4 pb-2 focus:outline-none whitespace-pre-wrap leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
<div
data-component={newSession() ? "session-new-design-text" : "session-composer-text"}
class="absolute top-0 inset-x-0 px-4 pt-4 pointer-events-none whitespace-nowrap truncate leading-5 text-[13px] font-[440] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)]"
classList={{ "font-mono!": store.mode === "shell", hidden: prompt.dirty() }}
>
{designPlaceholder()}
</div>
</div>
{placeholder()}
</div>
<div class="flex h-11 items-center px-2">
<div class="flex min-w-0 flex-1 items-center gap-0">
{fileAttachmentInput()}
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<IconButton
data-action="prompt-attach"
type="button"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
/>
</TooltipKeybind>
<Show when={newSession()}>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
<Icon name="sliders" size="small" />
</div>
<Select
size="normal"
options={worktrees()}
current={currentWorktree()}
label={worktreeLabel}
onSelect={(value) => {
if (value) props.onNewSessionWorktreeChange?.(value)
restoreFocus()
}}
class="max-w-[175px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 text-[13px] font-[440] leading-4 text-v2-text-text-faint"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-workspace" }}
variant="ghost"
/>
</div>
</Show>
{modelControl()}
</div>
</div>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 bottom-0"
style={{
height: space,
background:
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
}}
/>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
@@ -1547,348 +1407,209 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted shadow-[var(--v2-elevation-button-contrast)] disabled:opacity-50"
style={{
"background-image":
"linear-gradient(180deg,var(--v2-alpha-light-20) 0%,var(--v2-alpha-light-0) 100%),linear-gradient(90deg,var(--v2-background-bg-contrast) 0%,var(--v2-background-bg-contrast) 100%)",
}}
class="size-8"
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</DockShellForm>
</Match>
<Match when>
<DockShellForm
onSubmit={handleSubmit}
classList={{
"group/prompt-input": true,
"focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.draggingType !== null,
[props.class ?? ""]: !!props.class,
}}
>
<PromptDragOverlay
type={store.draggingType}
label={language.t(
store.draggingType === "@mention" ? "prompt.dropzone.file.label" : "prompt.dropzone.label",
)}
/>
<PromptContextItems
items={contextItems()}
active={(item) => {
const active = comments.active()
return !!item.commentID && item.commentID === active?.id && item.path === active?.file
}}
openComment={openComment}
remove={(item) => {
if (item.commentID) comments.remove(item.path, item.commentID)
prompt.context.remove(item.key)
}}
t={(key) => language.t(key as Parameters<typeof language.t>[0])}
/>
<PromptImageAttachments
attachments={imageAttachments()}
onOpen={(attachment) =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
onRemove={removeAttachment}
removeLabel={language.t("prompt.attachment.remove")}
/>
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div
class="relative"
onMouseDown={(e) => {
const target = e.target
if (!(target instanceof HTMLElement)) return
if (target.closest('[data-action="prompt-attach"], [data-action="prompt-submit"]')) {
return
}
editorRef?.focus()
aria-hidden={store.mode !== "normal"}
class="pointer-events-auto"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<div
class="relative max-h-[240px] overflow-y-auto no-scrollbar"
ref={(el) => (scrollRef = el)}
style={{ "scroll-padding-bottom": space }}
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<div
data-component="prompt-input"
ref={(el) => {
editorRef = el
props.ref?.(el)
}}
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
contenteditable="true"
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
inputMode="text"
// @ts-expect-error
autocomplete="off"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
"w-full pl-3 pr-2 pt-2 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
style={{ "padding-bottom": space }}
/>
<div
class="absolute top-0 inset-x-0 pl-3 pr-2 pt-2 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate"
classList={{ "font-mono!": store.mode === "shell" }}
style={{ "padding-bottom": space, display: prompt.dirty() ? "none" : undefined }}
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-8 p-0"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
{placeholder()}
</div>
</div>
<div
aria-hidden="true"
class="pointer-events-none absolute inset-x-0 bottom-0"
style={{
height: space,
background:
"linear-gradient(to top, var(--surface-raised-stronger-non-alpha) calc(100% - 20px), transparent)",
}}
/>
<div class="pointer-events-none absolute bottom-2 right-2 flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
accept={ACCEPTED_FILE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const list = e.currentTarget.files
if (list) void addAttachments(Array.from(list))
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1 pointer-events-auto">
<Tooltip placement="top" inactive={!working() && blank()} value={tip()}>
<IconButton
data-action="prompt-submit"
type="submit"
disabled={!working() && blank()}
tabIndex={store.mode === "normal" ? undefined : -1}
icon={stopping() ? "stop" : store.mode === "shell" ? "arrow-undo-down" : "arrow-up"}
variant="primary"
class="size-8"
aria-label={stopping() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>
</div>
</div>
<div class="pointer-events-none absolute bottom-2 left-2">
<div
aria-hidden={store.mode !== "normal"}
class="pointer-events-auto"
style={{
"pointer-events": buttonsSpring() > 0.5 ? "auto" : "none",
}}
>
<TooltipKeybind
placement="top"
title={language.t("prompt.action.attachFile")}
keybind={command.keybind("file.attach")}
>
<Button
data-action="prompt-attach"
type="button"
variant="ghost"
class="size-8 p-0"
style={buttons()}
onClick={pick}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
</div>
</div>
<Icon name="plus" class="size-4.5" />
</Button>
</TooltipKeybind>
</div>
</DockShellForm>
<Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
</div>
</div>
</DockShellForm>
<Show when={store.mode === "normal" || store.mode === "shell"}>
<DockTray attach="top">
<div class="px-1.75 pt-5.5 pb-2 flex items-center gap-2 min-w-0">
<div class="flex items-center gap-1.5 min-w-0 flex-1 relative">
<div
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
style={{
padding: "0 0px 0 8px",
...shell(),
}}
>
<Icon name="console" />
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
<div class="flex-1" />
<Button
variant="ghost"
class="text-text-base"
onClick={() => {
setStore("mode", "normal")
}}
>
{language.t("common.cancel")}
</Button>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
<Show when={!agentsLoading()}>
<div
class="h-7 flex items-center gap-1.5 min-w-0 absolute inset-0"
style={{
padding: "0 0px 0 8px",
...shell(),
}}
data-component="prompt-agent-control"
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Icon name="console" />
<span class="truncate text-13-medium text-text-base">{language.t("prompt.mode.shell")}</span>
<div class="flex-1" />
<Button
variant="ghost"
class="text-text-base"
onClick={() => {
setStore("mode", "normal")
}}
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
{language.t("common.cancel")}
</Button>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
onSelect={(value) => {
local.agent.set(value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-agent" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
<div class="flex items-center gap-1.5 min-w-0 flex-1 h-7">
<Show when={!agentsLoading()}>
<div
data-component="prompt-agent-control"
style={agentsShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
</Show>
<Show when={!providersLoading()}>
<Show when={store.mode !== "shell"}>
<div
data-component="prompt-model-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</div>
<Show when={variants().length > 2}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={agentNames()}
current={local.agent.current()?.name ?? ""}
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.agent.set(value)
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-agent" }}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
<Show when={!providersLoading()}>
<Show when={store.mode !== "shell"}>
<div
data-component="prompt-model-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
})
}}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
model={local.model}
triggerAs={Button}
triggerProps={{
variant: "ghost",
size: "normal",
style: control(),
class: "min-w-0 max-w-[320px] text-13-regular text-text-base group",
"data-action": "prompt-model",
}}
onClose={restoreFocus}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon
id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
</div>
<Show when={variants().length > 2}>
<div
data-component="prompt-variant-control"
style={providersShouldFadeIn() ? { animation: "fade-in 0.3s" } : undefined}
>
<TooltipKeybind
placement="top"
gutter={4}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Select
size="normal"
options={variants()}
current={local.model.variant.current() ?? "default"}
label={(x) => (x === "default" ? language.t("common.default") : x)}
onSelect={(value) => {
local.model.variant.set(value === "default" ? undefined : value)
restoreFocus()
}}
class="capitalize max-w-[160px] text-text-base"
valueClass="truncate text-13-regular text-text-base"
triggerStyle={control()}
triggerProps={{ "data-action": "prompt-model-variant" }}
variant="ghost"
/>
</TooltipKeybind>
</div>
</Show>
</Show>
</Show>
</div>
</div>
</Show>
</Show>
</div>
</DockTray>
</Show>
</Match>
</Switch>
</div>
</div>
</DockTray>
</Show>
</div>
)
}
@@ -3,4 +3,3 @@ export { SessionContextTab } from "./session-context-tab"
export { SortableTab, FileVisual } from "./session-sortable-tab"
export { SortableTerminalTab } from "./session-sortable-terminal-tab"
export { NewSessionView } from "./session-new-view"
export { NewSessionDesignView } from "./session-new-design-view"
@@ -1,78 +0,0 @@
import type { JSX } from "solid-js"
import { createMemo } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { getFilename } from "@opencode-ai/core/util/path"
import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { WordmarkV2 } from "@opencode-ai/ui/v2/components/wordmark-v2.jsx"
const MAIN_WORKTREE = "main"
export function NewSessionDesignView(props: { worktree: string; children: JSX.Element }) {
const globalSync = useGlobalSync()
const layout = useLayout()
const navigate = useNavigate()
const sdk = useSDK()
const server = useServer()
const sync = useSync()
const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const projects = createMemo(() => {
const roots = globalSync.data.project.map((project) => project.worktree)
if (roots.includes(projectRoot())) return roots
return [projectRoot(), ...roots]
})
const branch = createMemo(() => sync.data.vcs?.branch ?? MAIN_WORKTREE)
const openProject = (directory: string | undefined) => {
if (!directory) return
if (directory === projectRoot()) return
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
}
return (
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep">
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
<div class="w-full max-w-[720px]">
<WordmarkV2 class="h-auto w-full text-v2-icon-icon-base" />
<div class="mt-8">
{props.children}
<div class="mt-3 flex h-7 items-center gap-0 pl-2">
<Select
size="normal"
variant="ghost"
options={projects()}
current={projectRoot()}
label={getFilename}
onSelect={openProject}
class="max-w-[203px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate text-[length:13px] font-[440] text-v2-text-text-faint"
/>
<div class="relative">
<div class="pointer-events-none absolute left-2 top-1/2 z-10 flex size-4 -translate-y-1/2 items-center justify-center">
<Icon name="branch" size="small" />
</div>
<Select
size="normal"
variant="ghost"
options={[branch()]}
current={branch()}
class="max-w-[240px] justify-start text-text-base [&_[data-component=icon]]:text-v2-icon-icon-muted"
valueClass="truncate pl-5 font-[440] text-v2-text-text-faint"
/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
+92 -201
View File
@@ -7,9 +7,8 @@ import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
@@ -19,9 +18,6 @@ import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useGlobalSync } from "@/context/global-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -44,21 +40,13 @@ type TauriApi = {
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
const legacyTitlebarHeight = 40
const v2TitlebarHeight = 44
const titlebarHeight = 40
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const USE_V2_TITLEBAR = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export type TitlebarUpdate = {
version: () => string | undefined
installing: () => boolean
install: () => void
}
export function Titlebar(props: { update?: TitlebarUpdate }) {
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
@@ -71,16 +59,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const electronWindows = createMemo(() => windows() && !tauriApi())
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = USE_V2_TITLEBAR ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px`
if (mac()) return `${titlebarHeight / zoom()}px`
if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
return undefined
}
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
@@ -197,47 +183,19 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
return (
<header
classList={{
"shrink-0 relative overflow-hidden flex flex-row": true,
"h-11 bg-v2-background-bg-deep": USE_V2_TITLEBAR,
"h-10 bg-background-base": !USE_V2_TITLEBAR,
}}
style={{
"min-height": minHeight(),
"padding-left": mac() ? `${84 / zoom()}px` : 0,
width: electronWindows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined,
"max-width": electronWindows()
? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))`
: undefined,
"align-self": electronWindows() ? "flex-start" : undefined,
}}
class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
data-tauri-drag-region
onMouseDown={drag}
onDblClick={maximize}
>
<Switch>
<Match when={USE_V2_TITLEBAR}>
<Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
{(_) => {
const globalSync = useGlobalSync()
const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const openNewSession = () => {
if (params.dir) {
navigate(`/${params.dir}/session`)
return
}
const project = layout.projects.list()[0]
if (!project) {
navigate("/")
return
}
navigate(`/${base64Encode(project.worktree)}/session`)
}
type Tab = { dir: string; sessionId: string; href: string }
type Tab = { dir: string; sessionId: string; params: any; href: string }
const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = createStore<Tab[]>(
@@ -247,6 +205,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
{
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
},
]
@@ -289,15 +248,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
})
})
const projects = createMemo(() => layout.projects.list())
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
@@ -312,73 +267,78 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
})
return (
<div
class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3 py-2"
classList={{
"pl-2": mac(),
"pl-4": !mac(),
}}
>
<div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
<ChannelIndicator />
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" />
<WindowsAppMenu command={command} platform={platform} />
</Show>
<IconButtonV2
variant="ghost-muted"
size="large"
as="a"
href="/"
class="!w-9"
icon={<IconV2 name="grid-plus" />}
state={!!homeMatch() ? "pressed" : undefined}
/>
<div class="flex min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden">
<div class="flex min-w-0 flex-row items-center gap-1.5 overflow-hidden">
<For each={tabsEnriched()}>
{(tab, i) => (
<>
{i() !== 0 && (
<div class="w-[1.5px] h-3 shrink-0 rounded-full bg-[var(--v2-background-bg-layer-02)]" />
)}
<TabNavItem
href={tab.href}
title={tab.info.title}
project={projectForSession(tab.info, projects(), projectByID())}
directory={tab.dir}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
</For>
</div>
<Show
when={creating() && params.dir}
fallback={
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="shrink-0"
icon={<IconV2 name="plus" />}
onClick={openNewSession}
aria-label={language.t("command.session.new")}
/>
}
>
<NewSessionTabItem
href={`/${params.dir}/session`}
title={language.t("command.session.new")}
onClose={() => navigate(tabsEnriched().at(-1)?.href ?? "/")}
variant="ghost-muted"
size="large"
class="!w-8"
state={!!useMatch(() => "/")() ? "pressed" : undefined}
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
stroke="currentColor"
stroke-miterlimit="10"
stroke-linecap="square"
/>
</Show>
<div class="min-w-0 flex-1" />
</svg>
</IconButtonV2>
<div class="flex flex-row items-center gap-2">
<For each={tabsEnriched()}>
{(tab, i) => (
<>
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
<TabNavItem
href={tab.href}
title={tab.info.title}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
</For>
</div>
<TitlebarUpdatePill update={props.update} />
<Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
<button>
<div class="p-1.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
stroke="#808080"
stroke-linejoin="round"
/>
</svg>
</div>
</button>
<div class="flex-1" />
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
stroke="#3A3A3A"
/>
</svg>
</button>*/}
</div>
)
}}
@@ -398,7 +358,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
{/*<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />*/}
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
@@ -542,51 +502,19 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
)
}
function TitlebarUpdatePill(props: { update?: TitlebarUpdate }) {
const language = useLanguage()
const version = () => props.update?.version()
return (
<Show when={version() !== undefined}>
<button
type="button"
class="h-5 shrink-0 rounded-[27px] bg-[var(--v2-background-bg-accent)] px-2.5 text-[11px] font-[530] leading-[1.1] tracking-[-0.04px] text-[var(--v2-text-text-contrast)] disabled:opacity-60"
onClick={() => props.update?.install()}
disabled={props.update?.installing()}
aria-label={language.t("toast.update.action.installRestart")}
title={version() ? `Update ${version()}` : undefined}
>
Update
</button>
</Show>
)
}
function DesktopTitlebarIconButton(props: Parameters<typeof IconButtonV2>[0]) {
return
}
function TabNavItem(props: {
href: string
title: string
project?: LocalProject
directory: string
hideClose?: boolean
onClose: () => void
}) {
function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group relative flex h-7 min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
data-active={isActive()}
>
<a
href={props.href}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none tracking-[-0.04px] text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base"
class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
>
<ProjectTabAvatar project={props.project} directory={props.directory} />
<span class="truncate">{props.title}</span>
{props.title}
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
@@ -602,60 +530,23 @@ function TabNavItem(props: {
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
onClick={props.onClose}
icon={<IconV2 name="xmark-small" />}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
}
/>
</div>
</div>
)
}
function ProjectTabAvatar(props: { project?: LocalProject; directory: string }) {
return (
<AvatarV2
fallback={displayName(props.project ?? { worktree: props.directory })}
src={getProjectAvatarSource(props.project?.id, props.project?.icon)}
kind="org"
size="small"
{...getAvatarColors(props.project?.icon?.color)}
class="size-4 rounded"
/>
)
}
function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) {
return (
<div class="group relative flex h-7 w-[135px] min-w-24 max-w-60 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--v2-overlay-simple-overlay-pressed)] pl-1.5 pr-8 whitespace-nowrap focus-within:outline focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-[var(--v2-border-border-focus)]">
<a
href={props.href}
aria-current="page"
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-none text-[var(--v2-text-text-base)]"
>
<span class="flex size-4 shrink-0 rotate-90 items-center justify-center">
<IconV2 name="edit" />
</span>
<span class="truncate">{props.title}</span>
</a>
<div class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onClose()
}}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div>
)
}
function ChannelIndicator() {
return (
<>
@@ -2,8 +2,6 @@ import { Show, type JSX } from "solid-js"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { useCommand } from "@/context/command"
import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu"
@@ -12,7 +10,6 @@ import { usePlatform } from "@/context/platform"
export function WindowsAppMenu(props: {
command: ReturnType<typeof useCommand>
platform: ReturnType<typeof usePlatform>
variant?: "legacy" | "v2"
}) {
let lastFocused: HTMLElement | undefined
@@ -48,32 +45,15 @@ export function WindowsAppMenu(props: {
return (
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
{props.variant === "v2" ? (
<div
data-component="desktop-icon-button"
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
>
<DropdownMenu.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="large"
icon={<IconV2 name="menu" />}
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
</div>
) : (
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
)}
<DropdownMenu.Trigger
as={IconButton}
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md shrink-0"
aria-label="OpenCode menu"
onPointerDown={rememberFocus}
onKeyDown={rememberFocus}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="desktop-app-menu">
<DropdownMenu.Group>
+2 -18
View File
@@ -9,23 +9,13 @@ type OpenDirectoryPickerOptions = { title?: string; multiple?: boolean }
type OpenFilePickerOptions = { title?: string; multiple?: boolean; accept?: string[]; extensions?: string[] }
type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type UpdateInfo = { updateAvailable: boolean; version?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
export type FatalRendererErrorLog = {
error: string
url: string
version?: string
platform: PlatformName
os?: DesktopOS
}
export type Platform = {
/** Platform discriminator */
platform: PlatformName
platform: "web" | "desktop"
/** Desktop OS (Tauri only) */
os?: DesktopOS
os?: "macos" | "windows" | "linux"
/** App version */
version?: string
@@ -101,12 +91,6 @@ export type Platform = {
/** Read image from clipboard (desktop only) */
readClipboardImage?(): Promise<File | null>
/** Export collected diagnostic logs (desktop only) */
exportDebugLogs?(): Promise<string>
/** Record a fatal renderer error in platform logs (desktop only) */
recordFatalRendererError?(error: FatalRendererErrorLog): Promise<void>
}
export type DisplayBackend = "auto" | "wayland"
-2
View File
@@ -80,7 +80,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
{ type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } },
{ type: "item", label: "Reload Webview", action: "view.reload" },
{ type: "item", label: "Restart", action: "app.relaunch" },
{ type: "item", label: "Export Logs...", command: "logs.export" },
{ type: "separator" },
{ type: "item", role: "hide" },
{ type: "item", role: "hideOthers" },
@@ -202,7 +201,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [
items: [
{ type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" },
{ type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" },
{ type: "item", label: "Export Logs...", command: "logs.export" },
{ type: "separator" },
{
type: "item",
-9
View File
@@ -470,7 +470,6 @@ export const dict = {
"error.page.action.restart": "Restart",
"error.page.action.report": "Report Error",
"error.page.action.reported": "Error Reported",
"error.page.action.exportLogs": "Export Logs",
"error.page.action.checking": "Checking...",
"error.page.action.checkUpdates": "Check for updates",
"error.page.action.updateTo": "Update to {{version}}",
@@ -526,14 +525,6 @@ export const dict = {
"home.recentProjects": "Recent projects",
"home.empty.title": "No recent projects",
"home.empty.description": "Get started by opening a local project",
"home.title": "Home",
"home.projects": "Projects",
"home.project.add": "Add project",
"home.sessions.search.placeholder": "Search sessions",
"home.sessions.empty": "No sessions found",
"home.sessions.group.today": "Today",
"home.sessions.group.yesterday": "Yesterday",
"home.sessions.group.older": "Older",
"session.tab.session": "Session",
"session.tab.review": "Review",
-8
View File
@@ -498,14 +498,6 @@ export const dict = {
"home.recentProjects": "最近项目",
"home.empty.title": "没有最近项目",
"home.empty.description": "通过打开本地项目开始使用",
"home.title": "主页",
"home.projects": "项目",
"home.project.add": "添加项目",
"home.sessions.search.placeholder": "搜索会话",
"home.sessions.empty": "未找到会话",
"home.sessions.group.today": "今天",
"home.sessions.group.yesterday": "昨天",
"home.sessions.group.older": "更早",
"session.tab.session": "会话",
"session.tab.review": "审查",
+2
View File
@@ -43,7 +43,9 @@
width: 100%;
height: 100%;
border-radius: 999px;
background: var(--session-progress-color);
clip-path: inset(0 100% 0 0 round 999px);
animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite;
will-change: clip-path;
}
+1 -1
View File
@@ -2,6 +2,6 @@ export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"
+2 -36
View File
@@ -2,7 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import * as Sentry from "@sentry/solid"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, createSignal, onMount, Show } from "solid-js"
import { Component, createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
@@ -221,30 +221,12 @@ interface ErrorPageProps {
export const ErrorPage: Component<ErrorPageProps> = (props) => {
const platform = usePlatform()
const language = useLanguage()
const formattedError = () => formatError(props.error, language.t)
let recordedFatalError: Promise<void> | undefined
const [store, setStore] = createStore({
checking: false,
version: undefined as string | undefined,
actionError: undefined as string | undefined,
})
function ensureFatalErrorRecorded() {
recordedFatalError ??=
platform.recordFatalRendererError?.({
error: formattedError(),
url: location.href,
version: platform.version,
platform: platform.platform,
os: platform.os,
}) ?? Promise.resolve()
return recordedFatalError
}
onMount(() => {
void ensureFatalErrorRecorded().catch(() => undefined)
})
async function checkForUpdates() {
if (!platform.checkUpdate) return
setStore("checking", true)
@@ -272,17 +254,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
})
}
async function exportDebugLogs() {
const exportLogs = platform.exportDebugLogs
if (!exportLogs) return
await ensureFatalErrorRecorded()
.then(() => exportLogs())
.then(() => setStore("actionError", undefined))
.catch((err) => {
setStore("actionError", formatError(err, language.t))
})
}
return (
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
@@ -292,7 +263,7 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<p class="text-sm text-text-weak">{language.t("error.page.description")}</p>
</div>
<TextField
value={formattedError()}
value={formatError(props.error, language.t)}
readOnly
copyable
multiline
@@ -304,11 +275,6 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
<Button size="large" onClick={platform.restart}>
{language.t("error.page.action.restart")}
</Button>
<Show when={platform.platform === "desktop" && platform.exportDebugLogs}>
<Button size="large" variant="ghost" onClick={exportDebugLogs}>
{language.t("error.page.action.exportLogs")}
</Button>
</Show>
<Show when={Sentry.isEnabled}>
{(_) => {
const [reported, setReported] = createSignal(false)
+58 -626
View File
@@ -1,15 +1,7 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createMemo, createSignal, For, Match, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
import { createMemo, For, Match, Switch } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Logo } from "@opencode-ai/ui/logo"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Avatar as AvatarV2 } from "@opencode-ai/ui/v2/components/avatar-v2.jsx"
import { ButtonV2 } from "@opencode-ai/ui/v2/components/button-v2.jsx"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/components/icon.jsx"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { getAvatarColors, useLayout, type LocalProject } from "@/context/layout"
import { useLayout } from "@/context/layout"
import { useNavigate } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { Icon } from "@opencode-ai/ui/icon"
@@ -18,44 +10,11 @@ import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { useServer } from "@/context/server"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
import { getFilename } from "@opencode-ai/core/util/path"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { messageAgentColor } from "@/utils/agent"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
const HOME_SESSION_LIMIT = 15
const HOME_ROW =
"flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] border-0 bg-transparent text-left [font-weight:530] text-v2-text-text-muted transition-colors duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
const HOME_PROJECT_NAV_ROW = `${HOME_ROW} h-8 gap-1.5 px-3 [&>span]:min-w-0 [&>span]:overflow-hidden [&>span]:text-ellipsis [&>span]:whitespace-nowrap`
const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]"
type HomeSessionRecord = {
session: Session
project: LocalProject
projectName: string
}
type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
sessions: HomeSessionRecord[]
}
export default function Home() {
if (USE_HOME_DESIGN) return <HomeDesign />
return <LegacyHome />
}
function HomeDesign() {
const sync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
@@ -63,415 +22,6 @@ function HomeDesign() {
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const [state, setState] = createStore({ search: "", project: undefined as string | undefined })
const projects = createMemo(() => layout.projects.list())
const selectedProject = createMemo(
() => projects().find((project) => project.worktree === state.project) ?? projects()[0],
)
const projectDirectories = createMemo(() => {
const project = selectedProject()
if (!project) return []
return [project.worktree, ...(project.sandboxes ?? [])]
})
const search = createMemo(() => state.search.trim())
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
return null
},
}))
const projectByID = createMemo(
() => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const records = createMemo(() =>
[
...new Map(
projectDirectories()
.flatMap((directory) => sortedRootSessions(sync.child(directory, { bootstrap: false })[0], Date.now()))
.map((session) => [`${pathKey(session.directory)}:${session.id}`, session] as const),
).values(),
]
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.flatMap((session) => {
const project = projectForSession(session, projects(), projectByID())
if (!project) return []
return {
session,
project,
projectName: displayName(project),
}
})
.filter((record) => {
const value = search().toLowerCase()
if (!value) return true
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)
})
.slice(0, HOME_SESSION_LIMIT),
)
const groups = createMemo(() => groupSessions(records(), language))
function selectProject(directory: string) {
if (!projects().some((project) => project.worktree === directory)) return
setState("project", directory)
}
function addProject(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
setState("project", directory)
}
function openNewSession() {
const project = selectedProject()
if (!project) {
void chooseProject()
return
}
layout.projects.open(project.worktree)
server.projects.touch(project.worktree)
navigate(`/${base64Encode(project.worktree)}/session`)
}
function openSession(session: Session) {
const project = projectForSession(session, projects(), projectByID())
layout.projects.open(project?.worktree ?? session.directory)
server.projects.touch(project?.worktree ?? session.directory)
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
}
async function chooseProject() {
function resolve(result: string | string[] | null) {
if (Array.isArray(result)) {
result.forEach(addProject)
if (result[0]) setState("project", result[0])
return
}
if (result) addProject(result)
}
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: language.t("command.project.open"),
multiple: true,
})
resolve(result)
return
}
dialog.show(
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
}
function openSettings() {
void import("@/components/dialog-settings").then((x) => {
dialog.show(() => <x.DialogSettings />)
})
}
return (
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
<HomeProjectColumn
projects={projects()}
selected={selectedProject()?.worktree}
selectProject={selectProject}
chooseProject={() => void chooseProject()}
openSettings={openSettings}
openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
language={language}
/>
<section
class="min-w-0 flex-1 flex flex-col overflow-y-hidden pt-12"
aria-label={language.t("sidebar.project.recentSessions")}
>
<HomeSessionSearch
value={state.search}
placeholder={language.t("home.sessions.search.placeholder")}
onInput={(value) => setState("search", value)}
/>
<div class="mt-3 overflow-auto flex-1">
<div class="pt-3 flex flex-col gap-6">
<Show when={!sessionLoad.isLoading} fallback={<HomeSessionSkeleton label={language.t("common.loading")} />}>
<Show
when={groups().length > 0}
fallback={
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader title={language.t("home.sessions.empty")} onNewSession={openNewSession} />
</div>
}
>
<For each={groups()}>
{(group, index) => (
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 ? openNewSession : undefined}
/>
<div class="flex min-w-0 flex-col gap-px">
<For each={group.sessions}>
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
</For>
</div>
</div>
)}
</For>
</Show>
</Show>
</div>
</div>
</section>
</div>
)
}
function HomeProjectColumn(props: {
projects: LocalProject[]
selected?: string
selectProject: (directory: string) => void
chooseProject: () => void
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
}) {
return (
<aside class="flex min-w-0 flex-col lg:pt-[52px]" aria-label={props.language.t("home.projects")}>
<div class="flex h-7 min-w-0 items-center justify-between pl-3">
<div class={HOME_SECTION_LABEL}>{props.language.t("home.projects")}</div>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="large"
class="titlebar-icon [&_[data-slot=icon-svg]]:text-v2-icon-icon-muted"
icon={<IconV2 name="folder-add-left" />}
onClick={props.chooseProject}
aria-label={props.language.t("home.project.add")}
/>
</div>
<div class="mt-4 flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<Show
when={props.projects.length > 0}
fallback={
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.chooseProject}
>
<IconV2 name="folder-add-left" size="small" />
<span>{props.language.t("home.project.add")}</span>
</button>
}
>
<For each={props.projects}>
{(project) => (
<button
type="button"
data-component="home-project-row"
class={HOME_PROJECT_NAV_ROW}
classList={{ "bg-v2-overlay-simple-overlay-hover": props.selected === project.worktree }}
data-selected={props.selected === project.worktree ? "" : undefined}
aria-current={props.selected === project.worktree ? "page" : undefined}
onClick={() => props.selectProject(project.worktree)}
>
<HomeProjectAvatar project={project} />
<span>{displayName(project)}</span>
</button>
)}
</For>
</Show>
</div>
<div class="mt-4 flex min-w-0 flex-col gap-1">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openSettings}
>
<IconV2 name="settings-gear" size="small" />
<span>{props.language.t("sidebar.settings")}</span>
</button>
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.openHelp}
>
<IconV2 name="help" size="small" />
<span>{props.language.t("sidebar.help")}</span>
</button>
</div>
</aside>
)
}
function HomeProjectAvatar(props: { project: LocalProject }) {
const name = createMemo(() => displayName(props.project))
return (
<AvatarV2
fallback={name()}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
kind="org"
size="small"
{...getAvatarColors(props.project.icon?.color)}
class="size-4 rounded"
/>
)
}
function HomeSessionSearch(props: { value: string; placeholder: string; onInput: (value: string) => void }) {
return (
<label class="ml-4 flex h-9 w-[calc(100%_-_48px)] sticky top-0 inset-x-0 items-center gap-2 rounded-[6px] bg-v2-background-bg-deep px-3 py-1 text-v2-icon-icon-muted transition-[background-color,box-shadow] duration-[120ms] ease-in-out focus-within:bg-v2-background-bg-base focus-within:shadow-[0_0_0_0.5px_var(--v2-border-border-focus),var(--v2-elevation-raised)]">
<IconV2 name="magnifying-glass" size="small" />
<input
class="min-w-0 flex-1 border-0 bg-transparent text-v2-text-text-base outline-0 [font-weight:440] placeholder:text-v2-text-text-faint"
value={props.value}
placeholder={props.placeholder}
aria-label={props.placeholder}
onInput={(event) => props.onInput(event.currentTarget.value)}
/>
</label>
)
}
function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => void }) {
const language = useLanguage()
return (
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class={HOME_SECTION_LABEL}>{props.title}</div>
<Show when={props.onNewSession}>
{(onNewSession) => (
<ButtonV2
data-action="home-new-session"
variant="ghost"
size="normal"
icon="edit"
class="h-7 px-2 text-v2-text-text-muted [font-weight:530]"
onClick={onNewSession()}
>
{language.t("command.session.new")}
</ButtonV2>
)}
</Show>
</div>
)
}
function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
const globalSync = useGlobalSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
const hasPermissions = createMemo(
() =>
!!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
return !permission.autoResponds(item, props.record.session.directory)
}),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return sessionStore.session_working(props.record.session.id)
})
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
return (
<button
type="button"
data-component="home-session-row"
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
onClick={() => props.openSession(props.record.session)}
>
<Show when={showStatus()}>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
</Show>
<span
class={`min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-base [font-weight:530] ${props.record.projectName ? "max-w-[min(70%,480px)] flex-[0_1_auto]" : "flex-[1_1_auto]"}`}
>
{title()}
</span>
<Show when={props.record.projectName}>
<span class="min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-v2-text-text-muted [font-weight:440]">
{props.record.projectName}
</span>
</Show>
</button>
)
}
function HomeSessionSkeleton(props: { label: string }) {
return (
<div class="flex min-w-0 flex-col gap-4">
<div class="flex h-7 min-w-0 items-center justify-between px-4">
<div class={HOME_SECTION_LABEL}>{props.label}</div>
</div>
<div class="flex min-w-0 flex-col gap-px" aria-hidden="true">
<For each={[0, 1, 2, 3]}>{() => <div class="h-10 rounded-[6px] bg-v2-background-bg-deep opacity-70" />}</For>
</div>
</div>
)
}
function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof useLanguage>): HomeSessionGroup[] {
const now = DateTime.local()
const yesterday = now.minus({ days: 1 })
const todaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"),
)
const yesterdaySessions = records.filter((record) =>
DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"),
)
const olderSessions = records.filter((record) => {
const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created)
return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day")
})
const olderTitle =
todaySessions.length === 0 && yesterdaySessions.length === 0
? language.t("sidebar.project.recentSessions")
: language.t("home.sessions.group.older")
return [
{ id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions },
{ id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions },
{ id: "older" as const, title: olderTitle, sessions: olderSessions },
].filter((group) => group.sessions.length > 0)
}
function LegacyHome() {
const sync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
const dialog = useDialog()
const navigate = useNavigate()
const server = useServer()
const language = useLanguage()
const [promptText, setPromptText] = createSignal("")
const [selectedAgent, setSelectedAgent] = createSignal("frontend-specialist")
const [showProjectsDropdown, setShowProjectsDropdown] = createSignal(false)
const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => {
return sync.data.project
@@ -480,8 +30,6 @@ function LegacyHome() {
.slice(0, 5)
})
const currentProject = createMemo(() => recent()[0]?.worktree)
const serverDotClass = createMemo(() => {
const healthy = server.healthy()
if (healthy === true) return "bg-icon-success-base"
@@ -520,185 +68,69 @@ function LegacyHome() {
}
}
function handleModelSelect() {
dialog.show(() => <DialogSelectModel />)
}
function toggleAgent() {
const agents = ["frontend-specialist", "build", "general"]
const nextIndex = (agents.indexOf(selectedAgent()) + 1) % agents.length
setSelectedAgent(agents[nextIndex])
}
function handleSubmit() {
const projectToOpen = currentProject()
if (projectToOpen) {
openProject(projectToOpen)
} else {
chooseProject()
}
}
const activeModelName = createMemo(() => {
const model = sync.data.config.model
if (!model) return "GPT-5.7 Pro"
const parts = model.split("/")
return parts[parts.length - 1]
})
return (
<div class="mx-auto mt-24 w-full max-w-2xl px-6 flex flex-col items-center">
<div class="flex flex-col items-center gap-3 mb-10">
<div onClick={chooseProject} class="cursor-pointer hover:opacity-25 transition-opacity duration-200">
<Logo class="w-48 opacity-15" />
</div>
<Button
size="normal"
variant="ghost"
class="text-12-regular text-text-weak px-3"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-1.5 rounded-full mr-2": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
</div>
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<Button
size="large"
variant="ghost"
class="mt-4 mx-auto text-14-regular text-text-weak"
onClick={() => dialog.show(() => <DialogSelectServer />)}
>
<div
classList={{
"size-2 rounded-full": true,
[serverDotClass()]: true,
}}
/>
{server.name}
</Button>
<Switch>
<Match when={recent().length > 0}>
<div class="w-full flex flex-col items-center gap-6">
<div class="text-20-medium text-text-strong text-center">{language.t("session.new.title")}</div>
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md relative">
<textarea
class="bg-transparent border-none outline-none text-14-regular text-text-base placeholder-text-weak w-full resize-none h-20 focus:outline-none"
placeholder="Ask anything, / for commands, @ for context..."
value={promptText()}
onInput={(e) => setPromptText(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
handleSubmit()
}
}}
/>
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={toggleAgent}
>
<Icon name="sliders" size="small" class="shrink-0" />
<span>Agent: {selectedAgent()}</span>
</Button>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={handleModelSelect}
>
<Icon name="brain" size="small" class="shrink-0" />
<span>Model: {activeModelName()}</span>
</Button>
<div class="relative">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={() => setShowProjectsDropdown(!showProjectsDropdown())}
>
<Icon name="folder" size="small" class="shrink-0" />
<span>Project: {currentProject() ? getFilename(currentProject()) : "Select Project"}</span>
</Button>
<Show when={showProjectsDropdown()}>
<div class="absolute left-0 mt-1 w-64 bg-surface-raised-base border border-border-base rounded-lg p-2 shadow-lg z-50 flex flex-col gap-1">
<div class="text-10-semibold text-text-weak px-2 py-1 uppercase tracking-wider">
{language.t("home.recentProjects")}
</div>
<For each={recent()}>
{(project) => (
<button
class="text-12-mono text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center justify-between w-full"
onClick={() => {
openProject(project.worktree)
setShowProjectsDropdown(false)
}}
>
<span class="truncate">{getFilename(project.worktree)}</span>
<span class="text-10-regular text-text-weak shrink-0 pl-2">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</span>
</button>
)}
</For>
<div class="border-t border-border-weak-base my-1" />
<button
class="text-12-medium text-text-strong text-left px-2 py-1.5 hover:bg-surface-raised-base-hover rounded flex items-center gap-2 w-full"
onClick={() => {
setShowProjectsDropdown(false)
chooseProject()
}}
>
<Icon name="folder-add-left" size="small" />
{language.t("command.project.open")}
</button>
</div>
</Show>
</div>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base border border-border-weak-base rounded-md cursor-default pointer-events-none"
>
<Icon name="branch" size="small" class="shrink-0" />
<span>Branch: dev</span>
</Button>
</div>
<Match when={sync.data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
<div class="flex gap-2 items-center justify-between pl-3">
<div class="text-14-medium text-text-strong">{language.t("home.recentProjects")}</div>
<Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
<ul class="flex flex-col gap-2">
<For each={recent()}>
{(project) => (
<Button
size="large"
variant="ghost"
class="text-14-mono text-left justify-between px-3"
onClick={() => openProject(project.worktree)}
>
{project.worktree.replace(homedir(), "~")}
<div class="text-14-regular text-text-weak">
{DateTime.fromMillis(project.time.updated ?? project.time.created).toRelative()}
</div>
</Button>
)}
</For>
</ul>
</div>
</Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}>
<div class="w-full flex flex-col items-center gap-6">
<div class="text-20-medium text-text-strong text-center">{language.t("home.empty.title")}</div>
<div class="w-full bg-surface-base border border-border-base rounded-xl p-4 flex flex-col gap-3 shadow-md">
<div class="text-14-regular text-text-weak w-full min-h-[4rem] cursor-pointer" onClick={chooseProject}>
Ask anything, / for commands, @ for context...
</div>
<div class="flex flex-wrap items-center gap-2 pt-3 border-t border-border-weak-base">
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={chooseProject}
>
<Icon name="folder" size="small" class="shrink-0" />
<span>Open project</span>
</Button>
<Button
size="small"
variant="ghost"
class="text-12-medium text-text-weak hover:text-text-strong flex items-center gap-1.5 px-2.5 py-1 bg-surface-raised-base hover:bg-surface-raised-base-hover border border-border-weak-base rounded-md"
onClick={handleModelSelect}
>
<Icon name="brain" size="small" class="shrink-0" />
<span>Model: {activeModelName()}</span>
</Button>
</div>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" />
<div class="flex flex-col gap-1 items-center justify-center">
<div class="text-14-medium text-text-strong">{language.t("home.empty.title")}</div>
<div class="text-12-regular text-text-weak">{language.t("home.empty.description")}</div>
</div>
<Button class="px-3 mt-1" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
</Switch>
+58 -109
View File
@@ -14,7 +14,6 @@ import {
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { useQuery } from "@tanstack/solid-query"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
@@ -62,7 +61,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key"
@@ -89,8 +88,6 @@ import {
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
import { SidebarContent } from "./layout/sidebar-shell"
const USE_HOME_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
@@ -154,7 +151,7 @@ export default function Layout(props: ParentProps) {
const currentDir = createMemo(() => route().dir)
const [state, setState] = createStore({
autoselect: !initialDirectory && !USE_HOME_DESIGN,
autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
@@ -165,35 +162,6 @@ export default function Layout(props: ParentProps) {
peeked: false,
})
const [update, setUpdate] = createStore({
installing: false,
})
const updateQuery = useQuery(() => ({
queryKey: ["desktop", "update"] as const,
enabled: () =>
!!platform.checkUpdate && !!platform.updateAndRestart && settings.ready() && settings.updates.startup(),
queryFn: () => platform.checkUpdate?.() ?? Promise.resolve({ updateAvailable: false, version: undefined }),
refetchInterval: (query) => (query.state.data?.updateAvailable ? false : 10 * 60 * 1000),
}))
const updateVersion = () => {
if (!settings.ready()) return
if (!settings.updates.startup()) return
if (!updateQuery.data?.updateAvailable) return
return updateQuery.data.version ?? ""
}
const installUpdate = () => {
if (!platform.updateAndRestart) return
setUpdate("installing", true)
void platform.updateAndRestart().catch(() => {
setUpdate("installing", false)
})
}
const titlebarUpdate: TitlebarUpdate = {
version: updateVersion,
installing: () => update.installing,
install: installUpdate,
}
const editor = createInlineEditorController()
const setBusy = (directory: string, value: boolean) => {
const key = pathKey(directory)
@@ -396,6 +364,58 @@ export default function Layout(props: ParentProps) {
setLocale(next)
}
const useUpdatePolling = () =>
onMount(() => {
if (!platform.checkUpdate || !platform.updateAndRestart) return
let toastId: number | undefined
let interval: ReturnType<typeof setInterval> | undefined
const pollUpdate = () =>
platform.checkUpdate!().then(({ updateAvailable, version }) => {
if (!updateAvailable) return
if (toastId !== undefined) return
toastId = showToast({
persistent: true,
icon: "download",
title: language.t("toast.update.title"),
description: language.t("toast.update.description", { version: version ?? "" }),
actions: [
{
label: language.t("toast.update.action.installRestart"),
onClick: async () => {
await platform.updateAndRestart!()
},
},
{
label: language.t("toast.update.action.notYet"),
onClick: "dismiss",
},
],
})
})
createEffect(() => {
if (!settings.ready()) return
if (!settings.updates.startup()) {
if (interval === undefined) return
clearInterval(interval)
interval = undefined
return
}
if (interval !== undefined) return
void pollUpdate()
interval = setInterval(pollUpdate, 10 * 60 * 1000)
})
onCleanup(() => {
if (interval === undefined) return
clearInterval(interval)
})
})
const useSDKNotificationToasts = () =>
onMount(() => {
const toastBySession = new Map<string, number>()
@@ -515,6 +535,7 @@ export default function Layout(props: ParentProps) {
})
})
useUpdatePolling()
useSDKNotificationToasts()
function scrollToSession(sessionId: string, sessionKey: string) {
@@ -1060,18 +1081,6 @@ export default function Layout(props: ParentProps) {
keybind: "mod+comma",
onSelect: () => openSettings(),
},
...(platform.platform === "desktop" && platform.exportDebugLogs
? [
{
id: "logs.export",
title: "Export logs",
category: language.t("command.category.settings"),
onSelect: () => {
void platform.exportDebugLogs?.()
},
},
]
: []),
{
id: "session.previous",
title: language.t("command.session.previous"),
@@ -1817,10 +1826,8 @@ export default function Layout(props: ParentProps) {
)
createEffect(() => {
document.documentElement.style.setProperty(
"--dialog-left-margin",
USE_HOME_DESIGN ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`,
)
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
})
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
@@ -2361,34 +2368,10 @@ export default function Layout(props: ParentProps) {
/>
)
if (USE_HOME_DESIGN) {
return (
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<main
class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict bg-v2-background-bg-base"
classList={{
"m-2 mt-0 rounded-[10px] shadow-[var(--v2-elevation-raised)] overflow-hidden": !!params.id || !params.dir,
}}
>
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
{props.children}
</Show>
</main>
{import.meta.env.DEV && <DebugBar />}
<Toast.Region />
</div>
)
}
return (
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
{autoselecting() ?? ""}
<Titlebar update={titlebarUpdate} />
<Show when={updateVersion() !== undefined}>
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
</Show>
<Titlebar />
<div class="flex-1 min-h-0 min-w-0 flex">
<div class="flex-1 min-h-0 relative">
<div class="size-full relative overflow-x-hidden">
@@ -2536,37 +2519,3 @@ export default function Layout(props: ParentProps) {
</div>
)
}
function UpdateAvailableToast(props: {
version: string
install: () => void
language: ReturnType<typeof useLanguage>
}) {
let toastId: number | undefined
onMount(() => {
toastId = showToast({
persistent: true,
icon: "download",
title: props.language.t("toast.update.title"),
description: props.language.t("toast.update.description", { version: props.version }),
actions: [
{
label: props.language.t("toast.update.action.installRestart"),
onClick: props.install,
},
{
label: props.language.t("toast.update.action.notYet"),
onClick: "dismiss",
},
],
})
})
onCleanup(() => {
if (toastId === undefined) return
toaster.dismiss(toastId)
})
return null
}
-23
View File
@@ -55,29 +55,6 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree)
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
if (icon?.override) return icon.override
if (icon?.color) return undefined
return icon?.url
}
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
session: Session,
projects: T[],
byID: Map<string, T>,
) {
const direct = byID.get(session.projectID)
if (direct) return direct
const directory = pathKey(session.directory)
return projects.find(
(project) =>
pathKey(project.worktree) === directory || project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory),
)
}
export const errorMessage = (err: unknown, fallback: string) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
@@ -15,7 +15,16 @@ import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg"
if (icon?.override) return icon?.override
if (icon?.color) return undefined
return icon?.url
}
export const ProjectIcon = (props: {
project: LocalProject
+51 -62
View File
@@ -30,7 +30,7 @@ import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useSearchParams } from "@solidjs/router"
import { NewSessionDesignView, NewSessionView, SessionHeader } from "@/components/session"
import { NewSessionView, SessionHeader } from "@/components/session"
import { useComments } from "@/context/comments"
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
import { useGlobalSync } from "@/context/global-sync"
@@ -73,7 +73,6 @@ const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
const USE_NEW_SESSION_DESIGN = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
type SessionHistoryWindowInput = {
sessionID: () => string | undefined
@@ -1649,60 +1648,6 @@ export default function Page() {
useUsageExceededDialogs()
const composerRegion = (placement: "dock" | "inline") => (
<SessionComposerRegion
state={composer}
ready={!store.deferRender && messagesReady()}
centered={placement === "dock" && centered()}
placement={placement}
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeChange={(value) => setStore("newSessionWorktree", value)}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
onResponseSubmit={resumeScroll}
followup={
params.id && !isChildSession()
? {
queue: queueEnabled,
items: followupDock(),
sending: sendingFollowup(),
edit: editingFollowup(),
onQueue: queueFollowup,
onAbort: () => {
const id = params.id
if (!id) return
setFollowup("paused", id, true)
},
onSend: (id) => {
void sendFollowup(params.id!, id, { manual: true })
},
onEdit: editFollowup,
onEditLoaded: clearFollowupEdit,
}
: undefined
}
revert={
rolled().length > 0
? {
items: rolled(),
restoring: restoring(),
disabled: reverting(),
onRestore: restore,
}
: undefined
}
setPromptDockRef={(el) => {
promptDock = el
}}
/>
)
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
{sessionSync() ?? ""}
@@ -1795,16 +1740,60 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<Show when={USE_NEW_SESSION_DESIGN} fallback={<NewSessionView worktree={newSessionWorktree()} />}>
<NewSessionDesignView worktree={newSessionWorktree()}>
{composerRegion("inline")}
</NewSessionDesignView>
</Show>
<NewSessionView worktree={newSessionWorktree()} />
</Match>
</Switch>
</div>
<Show when={params.id || !USE_NEW_SESSION_DESIGN}>{composerRegion("dock")}</Show>
<SessionComposerRegion
state={composer}
ready={!store.deferRender && messagesReady()}
centered={centered()}
inputRef={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
onSubmit={() => {
comments.clear()
resumeScroll()
}}
onResponseSubmit={resumeScroll}
followup={
params.id && !isChildSession()
? {
queue: queueEnabled,
items: followupDock(),
sending: sendingFollowup(),
edit: editingFollowup(),
onQueue: queueFollowup,
onAbort: () => {
const id = params.id
if (!id) return
setFollowup("paused", id, true)
},
onSend: (id) => {
void sendFollowup(params.id!, id, { manual: true })
},
onEdit: editFollowup,
onEditLoaded: clearFollowupEdit,
}
: undefined
}
revert={
rolled().length > 0
? {
items: rolled(),
restoring: restoring(),
disabled: reverting(),
onRestore: restore,
}
: undefined
}
setPromptDockRef={(el) => {
promptDock = el
}}
/>
<Show when={desktopReviewOpen()}>
<div onPointerDown={() => size.start()}>
@@ -22,10 +22,8 @@ export function SessionComposerRegion(props: {
state: SessionComposerState
ready: boolean
centered: boolean
placement?: "dock" | "inline"
inputRef: (el: HTMLDivElement) => void
newSessionWorktree: string
onNewSessionWorktreeChange?: (worktree: string) => void
onNewSessionWorktreeReset: () => void
onSubmit: () => void
onResponseSubmit: () => void
@@ -144,15 +142,11 @@ export function SessionComposerRegion(props: {
<div
ref={props.setPromptDockRef}
data-component="session-prompt-dock"
classList={{
"w-full flex flex-col justify-center items-center pointer-events-none": true,
"shrink-0 pb-3 bg-background-stronger": props.placement !== "inline",
}}
class="shrink-0 w-full pb-3 flex flex-col justify-center items-center bg-background-stronger pointer-events-none"
>
<div
classList={{
"w-full px-3 pointer-events-auto": true,
"max-w-[720px] px-0": props.placement === "inline",
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
@@ -262,10 +256,8 @@ export function SessionComposerRegion(props: {
fallback={
<Show when={!props.state.blocked()}>
<PromptInput
variant={props.placement === "inline" ? "new-session" : undefined}
ref={props.inputRef}
newSessionWorktree={props.newSessionWorktree}
onNewSessionWorktreeChange={props.onNewSessionWorktreeChange}
onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
edit={props.followup?.edit}
onEditLoaded={props.followup?.onEditLoaded}
@@ -1236,6 +1236,7 @@ export function MessageTimeline(props: {
onClick={props.onAutoScrollInteraction}
class="relative min-w-0 w-full h-full"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
@@ -1255,14 +1256,16 @@ export function MessageTimeline(props: {
}}
>
<Show when={workingStatus() !== "hidden" && settings.general.showSessionProgressBar()}>
<div data-component="session-progress" data-state={workingStatus()} aria-hidden="true">
<div
data-component="session-progress-bar"
style={{
background: tint() ?? "var(--icon-interactive-base)",
animation: `session-progress-whip ${bar.ms}ms infinite`,
}}
/>
<div
data-component="session-progress"
data-state={workingStatus()}
aria-hidden="true"
style={{
"--session-progress-color": tint() ?? "var(--icon-interactive-base)",
"--session-progress-ms": `${bar.ms}ms`,
}}
>
<div data-component="session-progress-bar" />
</div>
</Show>
<div class="h-12 w-full flex items-center justify-between gap-2">
-10
View File
@@ -5,13 +5,6 @@ import { fileURLToPath } from "url"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
return "dev"
})()
/**
* @type {import("vite").PluginOption}
*/
@@ -25,9 +18,6 @@ export default [
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
worker: {
format: "es",
},
@@ -0,0 +1,45 @@
import { Resource, waitUntil } from "@opencode-ai/console-resource"
export function createDataDumper(sessionId: string, requestId: string, projectId: string) {
return
if (Resource.App.stage !== "production") return
if (sessionId === "") return
let data: Record<string, any> = { sessionId, requestId, projectId }
let metadata: Record<string, any> = { sessionId, requestId, projectId }
return {
provideModel: (model?: string) => {
data.modelName = model
metadata.modelName = model
},
provideRequest: (request: string) => (data.request = request),
provideResponse: (response: string) => (data.response = response),
provideStream: (chunk: string) => (data.response = (data.response ?? "") + chunk),
flush: () => {
if (!data.modelName) return
const timestamp = new Date().toISOString().replace(/[^0-9]/g, "")
const year = timestamp.substring(0, 4)
const month = timestamp.substring(4, 6)
const day = timestamp.substring(6, 8)
const hour = timestamp.substring(8, 10)
const minute = timestamp.substring(10, 12)
const second = timestamp.substring(12, 14)
void waitUntil(
Resource.ZenDataNew.put(
`data/${data.modelName}/${year}/${month}/${day}/${hour}/${minute}/${second}/${requestId}.json`,
JSON.stringify({ timestamp, ...data }),
),
)
void waitUntil(
Resource.ZenDataNew.put(
`meta/${data.modelName}/${sessionId}/${requestId}.json`,
JSON.stringify({ timestamp, ...metadata }),
),
)
},
}
}
@@ -39,6 +39,7 @@ import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter as createIpRateLimiter } from "./ipRateLimiter"
import { createRateLimiter as createKeyRateLimiter } from "./keyRateLimiter"
import { createDataDumper } from "./dataDumper"
import { createTrialLimiter } from "./trialLimiter"
import { createStickyTracker } from "./stickyProviderTracker"
import { LiteData } from "@opencode-ai/console-core/lite.js"
@@ -102,6 +103,7 @@ export async function handler(
const zenApiKey = rawZenApiKey === "public" ? undefined : rawZenApiKey
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
const requestId = input.request.headers.get("x-opencode-request") ?? ""
const projectId = input.request.headers.get("x-opencode-project") ?? ""
const ocClient = input.request.headers.get("x-opencode-client") ?? ""
const userAgent = input.request.headers.get("user-agent") ?? ""
logger.metric({
@@ -114,16 +116,16 @@ export async function handler(
})
const zenData = ZenData.list(opts.modelList)
const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trialProvider, ip)
const trialProviders = await trialLimiter?.check()
const rateLimiter = modelInfo.allowAnonymous
? createIpRateLimiter(modelInfo.id, modelInfo.rateLimit, ip, input.request)
: createKeyRateLimiter(modelInfo.id, modelInfo.rateLimit, zenApiKey, input.request)
await rateLimiter?.check()
const authInfo = await authenticate(modelInfo, zenApiKey)
const stickyId = sessionId ? sessionId : (authInfo?.workspaceID ?? ip)
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, stickyId)
const stickyTracker = createStickyTracker(modelInfo.id, modelInfo.stickyProvider, sessionId)
const stickyProvider = await stickyTracker?.get()
const authInfo = await authenticate(modelInfo, zenApiKey)
const billingSource = validateBilling(authInfo, modelInfo)
logger.metric({ source: billingSource })
const modelTpmLimiter = createModelTpmLimiter(modelInfo.providers)
@@ -137,7 +139,8 @@ export async function handler(
zenData,
authInfo,
modelInfo,
stickyId,
ip,
sessionId,
trialProviders,
retry,
stickyProvider,
@@ -164,8 +167,13 @@ export async function handler(
if (Array.isArray(v)) return [[k, v]]
if (typeof v === "object") return [[k, replacer(v)]]
if (typeof v === "string") {
if (v === "$ip") return [[k, ip]]
if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : []
if (v === "$user") return stickyId ? [[k, stickyId]] : []
if (v === "$session") return sessionId ? [[k, sessionId]] : []
if (v === "$user") {
const user = sessionId ?? authInfo?.workspaceID ?? ip
return user ? [[k, user]] : []
}
if (v.startsWith("$header.")) {
const headerValue = input.request.headers.get(v.slice(8))
return headerValue ? [[k, headerValue]] : []
@@ -184,7 +192,7 @@ export async function handler(
method: "POST",
headers: (() => {
const headers = new Headers(input.request.headers)
providerInfo.modifyHeaders(headers, providerInfo.apiKey, stickyId)
providerInfo.modifyHeaders(headers, body, providerInfo.apiKey)
Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => {
headers.set(k, headers.get(v)!)
})
@@ -229,6 +237,10 @@ export async function handler(
const { providerInfo, reqBody, res, startTimestamp } = await retriableRequest()
// Store model request
dataDumper?.provideModel(providerInfo.storeModel)
dataDumper?.provideRequest(reqBody)
// Store sticky provider
if (res.status === 200) await stickyTracker?.set(providerInfo.id)
@@ -269,6 +281,8 @@ export async function handler(
const body = JSON.stringify(responseConverter(json))
logger.metric({ response_length: body.length })
logger.debug("RESPONSE: " + body)
dataDumper?.provideResponse(body)
dataDumper?.flush()
return new Response(body, {
status: resStatus,
statusText: res.statusText,
@@ -299,6 +313,7 @@ export async function handler(
response_length: responseLength,
"timestamp.last_byte": timestampLastByte,
})
dataDumper?.flush()
await rateLimiter?.track()
const usage = usageParser.retrieve()
if (usage) {
@@ -336,6 +351,7 @@ export async function handler(
responseLength += value.length
buffer += decoder.decode(value, { stream: true })
dataDumper?.provideStream(buffer)
const parts = buffer.split(providerInfo.streamSeparator)
buffer = parts.pop() ?? ""
@@ -474,7 +490,8 @@ export async function handler(
zenData: ZenData,
authInfo: AuthInfo,
modelInfo: ModelInfo,
stickyId: string,
ip: string,
sessionId: string,
trialProviders: string[] | undefined,
retry: RetryOptions,
stickyProvider: string | undefined,
@@ -524,10 +541,11 @@ export async function handler(
.flatMap((provider) => Array<typeof provider>(provider.weight).fill(provider))
// Use the last 4 characters of session ID to select a provider
const identifier = sessionId.length ? sessionId : ip
let h = 0
const l = stickyId.length
const l = identifier.length
for (let i = l - 4; i < l; i++) {
h = (h * 31 + stickyId.charCodeAt(i)) | 0 // 32-bit int
h = (h * 31 + identifier.charCodeAt(i)) | 0 // 32-bit int
}
const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
const provider = providers[index || 0]
@@ -28,7 +28,7 @@ export const anthropicHelper: ProviderHelper = ({ reqModel, providerModel }) =>
isBedrock
? `${providerApi}/model/${isBedrockModelArn ? encodeURIComponent(providerModel) : providerModel}/${isStream ? "invoke-with-response-stream" : "invoke"}`
: providerApi + "/messages",
modifyHeaders: (headers: Headers, apiKey: string, _stickyId: string) => {
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
if (isBedrock || isDatabricks) {
headers.set("Authorization", `Bearer ${apiKey}`)
} else {
@@ -30,7 +30,7 @@ export const googleHelper: ProviderHelper = ({ providerModel }) => ({
format: "google",
modifyUrl: (providerApi: string, isStream?: boolean) =>
`${providerApi}/models/${providerModel}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`,
modifyHeaders: (headers: Headers, apiKey: string, _stickyId: string) => {
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("x-goog-api-key", apiKey)
},
modifyBody: (body: Record<string, any>) => {
@@ -26,9 +26,9 @@ type Usage = {
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => {
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
headers.set("x-session-affinity", stickyId)
headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
},
modifyBody: (body: Record<string, any>, _workspaceID?: string) => {
return {
@@ -15,7 +15,7 @@ type Usage = {
export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
format: "openai",
modifyUrl: (providerApi: string) => providerApi + "/responses",
modifyHeaders: (headers: Headers, apiKey: string, _stickyId: string) => {
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>) => body,
@@ -41,7 +41,7 @@ export type ProviderHelper = (input: {
}) => {
format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, apiKey: string, stickyId: string) => void
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>) => Record<string, any>
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
streamSeparator: string
@@ -1,14 +1,10 @@
import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { ModelStickyProviderTable } from "@opencode-ai/console-core/schema/ip.sql.js"
export function createStickyTracker(
modelId: string,
stickyProvider: "strict" | "prefer" | undefined,
stickyId: string,
) {
export function createStickyTracker(modelId: string, stickyProvider: "strict" | "prefer" | undefined, session: string) {
if (!stickyProvider) return
if (!stickyId) return
const id = `${modelId}/${stickyId}`
if (!session) return
const id = `${modelId}/${session}`
let _providerId: string | undefined
return {
-319
View File
@@ -1,319 +0,0 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./filesystem"
import { EventV2 } from "./event"
export const ID = Schema.String.pipe(
Schema.brand("AccountV2.ID"),
withStatics((schema) => ({ create: () => schema.make("acc_" + Identifier.ascending()) })),
)
export type ID = typeof ID.Type
export const ServiceID = Schema.String.pipe(Schema.brand("ServiceID"))
export type ServiceID = typeof ServiceID.Type
export class OAuthCredential extends Schema.Class<OAuthCredential>("AccountV2.OAuthCredential")({
type: Schema.Literal("oauth"),
refresh: Schema.String,
access: Schema.String,
expires: NonNegativeInt,
}) {}
export class ApiKeyCredential extends Schema.Class<ApiKeyCredential>("AccountV2.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: "AccountV2.Credential",
})
export type Credential = Schema.Schema.Type<typeof Credential>
export class Info extends Schema.Class<Info>("AccountV2.Info")({
id: ID,
serviceID: ServiceID,
description: Schema.String,
credential: Credential,
}) {}
export class FileWriteError extends Schema.TaggedErrorClass<FileWriteError>()("AccountV2.FileWriteError", {
operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]),
cause: Schema.Defect,
}) {}
export type Error = FileWriteError
export const Event = {
Added: EventV2.define({
type: "account.added",
schema: {
account: Info,
},
}),
Removed: EventV2.define({
type: "account.removed",
schema: {
account: Info,
},
}),
Switched: EventV2.define({
type: "account.switched",
schema: {
serviceID: ServiceID,
from: Schema.optional(ID),
to: Schema.optional(ID),
},
}),
}
interface Writable {
version: 2
accounts: Record<string, Info>
active: Record<string, ID>
}
const decodeV1 = Schema.decodeUnknownOption(Schema.Record(Schema.String, Credential))
function migrate(old: Record<string, unknown>): Writable {
const accounts: Record<string, Info> = {}
const active: Record<string, ID> = {}
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 account = ID.make(id)
const brandedServiceID = ServiceID.make(serviceID)
accounts[id] = new Info({
id: account,
serviceID: brandedServiceID,
description: "default",
credential: parsed,
})
active[brandedServiceID] = account
}
return { version: 2, accounts, active }
}
export interface Interface {
readonly get: (id: ID) => Effect.Effect<Info | undefined, Error>
readonly all: () => Effect.Effect<Info[], Error>
readonly create: (input: {
serviceID: ServiceID
credential: Credential
description?: string
}) => Effect.Effect<Info | undefined, Error>
readonly update: (id: ID, updates: Partial<Pick<Info, "description" | "credential">>) => Effect.Effect<void, Error>
readonly remove: (id: ID) => Effect.Effect<void, Error>
readonly activate: (id: ID) => Effect.Effect<void, Error>
readonly active: (serviceID: ServiceID) => Effect.Effect<Info | undefined, Error>
readonly forService: (serviceID: ServiceID) => Effect.Effect<Info[], Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Account") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const global = yield* Global.Service
const events = yield* EventV2.Service
const file = path.join(global.data, "account.json")
const legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, Error> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
const write = (data: Writable) =>
fsys
.writeJson(file, data, 0o600)
.pipe(Effect.mapError((cause) => new FileWriteError({ operation: "write", cause })))
const state = SynchronizedRef.makeUnsafe(
yield* load().pipe(Effect.orElseSucceed((): Writable => ({ version: 2, accounts: {}, active: {} }))),
)
const activate = Effect.fn("AccountV2.activate")(function* (id: ID) {
const data = yield* SynchronizedRef.get(state)
const account = data.accounts[id]
if (!account) return
const activated = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const nextAccount = data.accounts[id]
if (!nextAccount) return [undefined, data] as const
const next = { ...data, active: { ...data.active, [nextAccount.serviceID]: id } }
yield* write(next)
return [{ serviceID: nextAccount.serviceID, from: data.active[nextAccount.serviceID], to: id }, next] as const
}),
)
if (activated) yield* events.publish(Event.Switched, activated)
})
const result: Interface = {
get: Effect.fn("AccountV2.get")(function* (id) {
return (yield* SynchronizedRef.get(state)).accounts[id]
}),
all: Effect.fn("AccountV2.all")(function* () {
return Object.values((yield* SynchronizedRef.get(state)).accounts)
}),
active: Effect.fn("AccountV2.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("AccountV2.list")(function* (serviceID) {
return Object.values((yield* SynchronizedRef.get(state)).accounts).filter((a) => a.serviceID === serviceID)
}),
create: Effect.fn("AccountV2.add")(function* (input) {
const id = ID.make(Identifier.ascending())
const account = new Info({
id,
serviceID: input.serviceID,
description: input.description ?? "default",
credential: input.credential,
})
const added = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const next = {
...data,
accounts: { ...data.accounts, [account.id]: account },
active: { ...data.active, [account.serviceID]: account.id },
}
yield* write(next)
return [
{
account,
switched: { serviceID: account.serviceID, from: data.active[account.serviceID], to: account.id },
},
next,
] as const
}),
)
yield* events.publish(Event.Added, { account: added.account })
yield* events.publish(Event.Switched, added.switched)
return added.account
}),
update: Effect.fn("AccountV2.update")(function* (id, updates) {
const existing = (yield* SynchronizedRef.get(state)).accounts[id]
if (!existing) return
yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
if (!data.accounts[id]) return [undefined, data] as const
const next = {
...data,
accounts: {
...data.accounts,
[id]: new Info({
id,
serviceID: existing.serviceID,
description: updates.description ?? existing.description,
credential: updates.credential ?? existing.credential,
}),
},
}
yield* write(next)
return [undefined, next] as const
}),
)
}),
remove: Effect.fn("AccountV2.remove")(function* (id) {
const removed = yield* SynchronizedRef.modifyEffect(
state,
Effect.fnUntraced(function* (data) {
const accounts = { ...data.accounts }
const active = { ...data.active }
const removed = accounts[id]
if (!removed) return [undefined, data] as const
const wasActive = active[removed.serviceID] === id
delete accounts[id]
const replacement = Object.values(accounts).find((account) => account.serviceID === removed.serviceID)
if (wasActive) {
if (replacement) active[removed.serviceID] = replacement.id
else delete active[removed.serviceID]
}
const next = { ...data, accounts, active }
yield* write(next)
return [
{
account: removed,
switched: wasActive ? { serviceID: removed.serviceID, from: id, to: replacement?.id } : undefined,
},
next,
] as const
}),
)
if (removed) {
yield* events.publish(Event.Removed, { account: removed.account })
if (removed.switched) yield* events.publish(Event.Switched, removed.switched)
}
}),
activate,
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(Global.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export * as AccountV2 from "./account"
-147
View File
@@ -1,147 +0,0 @@
export * as AgentV2 from "./agent"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PermissionV2 } from "./permission"
import { PluginV2 } from "./plugin"
import { ProviderV2 } from "./provider"
export const ID = Schema.String.pipe(Schema.brand("AgentV2.ID"))
export type ID = typeof ID.Type
export const Mode = Schema.Literals(["subagent", "primary", "all"]).annotate({ identifier: "AgentV2.Mode" })
export type Mode = typeof Mode.Type
export const Info = Schema.Struct({
name: ID,
description: Schema.optional(Schema.String),
mode: Mode,
hidden: Schema.Boolean.pipe(Schema.optional),
color: Schema.String.pipe(Schema.optional),
permission: PermissionV2.Ruleset,
model: ModelV2.Ref.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
options: ProviderV2.Options.pipe(Schema.optional),
steps: Schema.Int.pipe(Schema.optional),
}).annotate({ identifier: "AgentV2.Info" })
export type Info = typeof Info.Type
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("AgentV2.NotFound", {
agent: ID,
}) {}
export class InvalidDefaultError extends Schema.TaggedErrorClass<InvalidDefaultError>()("AgentV2.InvalidDefault", {
agent: ID,
reason: Schema.Literals(["missing", "subagent", "hidden"]),
}) {}
export class NoDefaultError extends Schema.TaggedErrorClass<NoDefaultError>()("AgentV2.NoDefault", {}) {}
export interface Interface {
readonly get: (agent: ID) => Effect.Effect<Info, NotFoundError>
readonly list: () => Effect.Effect<Info[]>
readonly update: (agent: ID, fn: (agent: Draft<Info>) => void) => Effect.Effect<void>
readonly remove: (agent: ID) => Effect.Effect<void>
readonly defaultInfo: () => Effect.Effect<Info, InvalidDefaultError | NoDefaultError>
readonly defaultAgent: () => Effect.Effect<ID, InvalidDefaultError | NoDefaultError>
readonly setDefault: (agent: ID) => Effect.Effect<void, NotFoundError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
let agents = HashMap.empty<ID, Info>()
let defaultAgent: ID | undefined
const result: Interface = {
get: Effect.fn("AgentV2.get")(function* (agent) {
const match = HashMap.get(agents, agent)
if (!match.valueOrUndefined) return yield* new NotFoundError({ agent })
return match.value
}),
list: Effect.fn("AgentV2.list")(function* () {
return pipe(
HashMap.toValues(agents),
Array.sortWith((agent) => agent.name, Order.String),
)
}),
update: Effect.fnUntraced(function* (agent, fn) {
const next = produce(
HashMap.get(agents, agent).pipe(
Option.getOrElse(
() =>
({
name: agent,
mode: "all",
permission: [],
options: {
headers: {},
body: {},
aisdk: {
provider: {},
request: {},
},
},
}) satisfies Info,
),
),
fn,
)
const updated = yield* plugin.trigger("agent.update", {}, { agent: next, cancel: false })
if (updated.cancel) return
agents = HashMap.set(agents, agent, { ...updated.agent, name: agent })
}),
remove: Effect.fn("AgentV2.remove")(function* (agent) {
const existing = Option.getOrUndefined(HashMap.get(agents, agent))
if (!existing) return
if ((yield* plugin.trigger("agent.remove", { agent: existing }, { cancel: false })).cancel) return
agents = HashMap.remove(agents, agent)
if (defaultAgent === agent) defaultAgent = undefined
}),
defaultInfo: Effect.fn("AgentV2.defaultInfo")(function* () {
const updated = yield* plugin.trigger("agent.default", {}, { agent: defaultAgent })
const selected = updated.agent
if (selected) {
const agent = yield* result
.get(selected)
.pipe(
Effect.catchTag("AgentV2.NotFound", () =>
Effect.fail(new InvalidDefaultError({ agent: selected, reason: "missing" })),
),
)
if (agent.mode === "subagent") return yield* new InvalidDefaultError({ agent: selected, reason: "subagent" })
if (agent.hidden === true) return yield* new InvalidDefaultError({ agent: selected, reason: "hidden" })
return agent
}
const visible = pipe(
yield* result.list(),
Array.findFirst((agent) => agent.mode !== "subagent" && agent.hidden !== true),
)
if (Option.isSome(visible)) return visible.value
return yield* new NoDefaultError()
}),
defaultAgent: Effect.fn("AgentV2.defaultAgent")(function* () {
return (yield* result.defaultInfo()).name
}),
setDefault: Effect.fn("AgentV2.setDefault")(function* (agent) {
yield* result.get(agent)
defaultAgent = agent
}),
}
return Service.of(result)
}),
)
export const defaultLayer = layer.pipe(Layer.provide(PluginV2.defaultLayer))
+264
View File
@@ -0,0 +1,264 @@
import path from "path"
import { Effect, Layer, Option, Schema, Context, SynchronizedRef } from "effect"
import { Identifier } from "./util/identifier"
import { NonNegativeInt, withStatics } from "./schema"
import { Global } from "./global"
import { AppFileSystem } from "./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 legacyFile = path.join(global.data, "auth.json")
const writeMigrated = Effect.fnUntraced(function* (raw: Record<string, unknown>) {
const migrated = migrate(raw)
yield* fsys
.writeJson(file, migrated, 0o600)
.pipe(Effect.mapError((cause) => new AuthFileWriteError({ operation: "migrate", cause })))
return migrated
})
const parseAuthContent = () => {
try {
return JSON.parse(process.env.OPENCODE_AUTH_CONTENT ?? "")
} catch {}
}
const load: () => Effect.Effect<Writable, AuthError> = Effect.fnUntraced(function* () {
if (process.env.OPENCODE_AUTH_CONTENT) {
const raw = parseAuthContent()
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
}
const legacy = yield* fsys.readJson(legacyFile).pipe(Effect.orElseSucceed(() => null))
if (legacy && typeof legacy === "object") return yield* writeMigrated(legacy as Record<string, unknown>)
const raw = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null))
if (raw && typeof raw === "object") {
if ("version" in raw && raw.version === 2) return raw as Writable
return yield* writeMigrated(raw as Record<string, unknown>)
}
return { version: 2, accounts: {}, active: {} }
})
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"
+53 -141
View File
@@ -1,6 +1,6 @@
export * as Catalog from "./catalog"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array, Scope, Stream } from "effect"
import { Context, Effect, HashMap, Layer, Option, Order, pipe, Schema, Array } from "effect"
import { produce, type Draft } from "immer"
import { ModelV2 } from "./model"
import { PluginV2 } from "./plugin"
@@ -8,9 +8,9 @@ import { ProviderV2 } from "./provider"
import { Location } from "./location"
import { EventV2 } from "./event"
export type ProviderRecord = {
type ProviderRecord = {
provider: ProviderV2.Info
models: Map<ModelV2.ID, ModelV2.Info>
models: HashMap.HashMap<ModelV2.ID, ModelV2.Info>
}
export class ProviderNotFoundError extends Schema.TaggedErrorClass<ProviderNotFoundError>()(
@@ -34,26 +34,10 @@ export const Event = {
}),
}
export type Context = {
data: readonly ProviderRecord[]
updateProvider: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
updateModel: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
provider: {
update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID) => void
}
model: {
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: Draft<ModelV2.Info>) => void) => void
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
}
}
export type Loader = (update: (ctx: Context) => void) => Effect.Effect<void>
export interface Interface {
readonly loader: () => Effect.Effect<Loader, never, Scope.Scope>
readonly provider: {
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info, ProviderNotFoundError>
readonly update: (providerID: ProviderV2.ID, fn: (provider: Draft<ProviderV2.Info>) => void) => Effect.Effect<void>
readonly all: () => Effect.Effect<ProviderV2.Info[]>
readonly available: () => Effect.Effect<ProviderV2.Info[]>
}
@@ -62,6 +46,11 @@ export interface Interface {
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
) => Effect.Effect<ModelV2.Info, ProviderNotFoundError | ModelNotFoundError>
readonly update: (
providerID: ProviderV2.ID,
modelID: ModelV2.ID,
fn: (model: Draft<ModelV2.Info>) => void,
) => Effect.Effect<void, ProviderNotFoundError>
readonly all: () => Effect.Effect<ModelV2.Info[]>
readonly available: () => Effect.Effect<ModelV2.Info[]>
readonly default: () => Effect.Effect<Option.Option<ModelV2.Info>>
@@ -80,11 +69,9 @@ export const layer = Layer.effect(
Effect.gen(function* () {
yield* Location.Service
let records = HashMap.empty<ProviderV2.ID, ProviderRecord>()
let loaders: { update: (ctx: Context) => void }[] = []
let defaultModel: { providerID: ProviderV2.ID; modelID: ModelV2.ID } | undefined
const plugin = yield* PluginV2.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const resolve = (model: ModelV2.Info) => {
const provider = Option.getOrThrow(HashMap.get(records, model.providerID)).provider
@@ -125,127 +112,29 @@ export const layer = Layer.effect(
return match.value
}
const normalizeEndpoint = (item: Draft<ProviderV2.Info> | Draft<ModelV2.Info>) => {
if (item.endpoint.type !== "aisdk" || typeof item.options.aisdk.provider.baseURL !== "string") return
item.endpoint.url = item.options.aisdk.provider.baseURL
delete item.options.aisdk.provider.baseURL
}
const clone = (input: HashMap.HashMap<ProviderV2.ID, ProviderRecord>) =>
HashMap.fromIterable(
HashMap.toEntries(input).map(([key, value]) => [key, { ...value, models: new Map(value.models) }] as const),
)
const context = (draft: {
records: HashMap.HashMap<ProviderV2.ID, ProviderRecord>
data: ProviderRecord[]
}): Context => {
const result: Context = {
data: draft.data,
updateProvider: (providerID, fn) => result.provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => result.model.update(providerID, modelID, fn),
provider: {
update: (providerID, fn) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
fn(draft)
normalizeEndpoint(draft)
})
const next = {
provider,
models: current?.models ?? new Map<ModelV2.ID, ModelV2.Info>(),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
},
remove: (providerID) => {
draft.records = HashMap.remove(draft.records, providerID)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data.splice(index, 1)
},
},
model: {
update: (providerID, modelID, fn) => {
const current = Option.getOrThrow(HashMap.get(draft.records, providerID))
const model = produce(current.models.get(modelID) ?? ModelV2.Info.empty(providerID, modelID), (draft) => {
fn(draft)
normalizeEndpoint(draft)
})
const next = {
provider: current.provider,
models: new Map(current.models).set(modelID, new ModelV2.Info({ ...model, id: modelID, providerID })),
}
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index === -1) draft.data.push(next)
else draft.data[index] = next
},
remove: (providerID, modelID) => {
const current = Option.getOrUndefined(HashMap.get(draft.records, providerID))
if (!current) return
const next = {
provider: current.provider,
models: new Map(current.models),
}
next.models.delete(modelID)
draft.records = HashMap.set(draft.records, providerID, next)
const index = draft.data.findIndex((item) => item.provider.id === providerID)
if (index !== -1) draft.data[index] = next
},
},
}
return result
}
const transform = Effect.fn("CatalogV2.transform")(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
})
const rebuild = Effect.fn("CatalogV2.rebuild")(function* () {
const draft = { records: HashMap.empty<ProviderV2.ID, ProviderRecord>(), data: [] as ProviderRecord[] }
for (const loader of loaders) loader.update(context(draft))
yield* plugin.trigger("catalog.transform", context(draft), {})
records = draft.records
})
yield* plugin.added().pipe(
Stream.runForEach((id) =>
Effect.gen(function* () {
const draft = { records: clone(records), data: HashMap.toValues(records) }
yield* plugin.triggerFor(id, "catalog.transform", context(draft), {})
records = draft.records
}),
),
Effect.forkIn(scope, { startImmediately: true }),
)
const result: Interface = {
loader: Effect.fn("CatalogV2.loader")(function* () {
const loader = { update: (_ctx: Context) => {} }
loaders = [...loaders, loader]
const scope = yield* Scope.Scope
yield* Scope.addFinalizer(
scope,
Effect.sync(() => {
loaders = loaders.filter((item) => item !== loader)
}).pipe(Effect.andThen(rebuild())),
)
return Effect.fnUntraced(function* (update) {
loader.update = update
yield* rebuild()
})
}),
provider: {
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
const record = yield* getRecord(providerID)
return record.provider
}),
update: Effect.fnUntraced(function* (providerID, fn) {
const current = Option.getOrUndefined(HashMap.get(records, providerID))
const provider = produce(current?.provider ?? ProviderV2.Info.empty(providerID), (draft) => {
fn(draft)
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
draft.endpoint.url = draft.options.aisdk.provider.baseURL
delete draft.options.aisdk.provider.baseURL
}
})
const updated = yield* plugin.trigger("provider.update", {}, { provider, cancel: false })
records = HashMap.set(records, providerID, {
provider: updated.provider,
models: current?.models ?? HashMap.empty<ModelV2.ID, ModelV2.Info>(),
})
}),
all: Effect.fn("CatalogV2.provider.all")(function* () {
return globalThis.Array.from(HashMap.values(records)).map((record) => record.provider)
}),
@@ -260,16 +149,39 @@ export const layer = Layer.effect(
model: {
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
const record = yield* getRecord(providerID)
const model = record.models.get(modelID)
const model = Option.getOrUndefined(HashMap.get(record.models, modelID))
if (!model) return yield* new ModelNotFoundError({ providerID, modelID })
return resolve(model)
}),
update: Effect.fnUntraced(function* (providerID, modelID, fn) {
const record = yield* getRecord(providerID)
const model = produce(
HashMap.get(record.models, modelID).pipe(Option.getOrElse(() => ModelV2.Info.empty(providerID, modelID))),
(draft) => {
fn(draft)
if (draft.endpoint.type === "aisdk" && typeof draft.options.aisdk.provider.baseURL === "string") {
draft.endpoint.url = draft.options.aisdk.provider.baseURL
delete draft.options.aisdk.provider.baseURL
}
},
)
const updated = yield* plugin.trigger("model.update", {}, { model, cancel: false })
if (updated.cancel) return
const next = new ModelV2.Info({ ...updated.model, id: modelID, providerID })
records = HashMap.set(records, providerID, {
provider: record.provider,
models: HashMap.set(record.models, modelID, next),
})
yield* events.publish(Event.ModelUpdated, { model: resolve(next) })
return
}),
all: Effect.fn("CatalogV2.model.all")(function* () {
return pipe(
records,
HashMap.toValues,
Array.flatMap((record) => globalThis.Array.from(record.models.values())),
Array.flatMap((record) => HashMap.toValues(record.models)),
Array.map(resolve),
Array.sortWith((item) => item.time.released.epochMilliseconds, Order.flip(Order.Number)),
)
@@ -305,12 +217,12 @@ export const layer = Layer.effect(
if (!record) return Option.none<ModelV2.Info>()
if (providerID === ProviderV2.ID.opencode) {
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
const gpt5Nano = Option.getOrUndefined(HashMap.get(record.models, ModelV2.ID.make("gpt-5-nano")))
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return Option.some(resolve(gpt5Nano))
}
const candidates = pipe(
globalThis.Array.from(record.models.values()),
HashMap.toValues(record.models),
Array.filter(
(model) =>
model.providerID === providerID &&
@@ -354,4 +266,4 @@ export const layer = Layer.effect(
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
export const defaultLayer = layer.pipe(Layer.provide(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
export const defaultLayer = layer.pipe(Layer.provideMerge(EventV2.defaultLayer), Layer.provide(PluginV2.defaultLayer))
+2 -2
View File
@@ -1,5 +1,3 @@
export * as EventV2 from "./event"
import { Context, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
import { Location } from "./location"
import { withStatics } from "./schema"
@@ -155,3 +153,5 @@ export const layer = Layer.effect(
)
export const defaultLayer = layer
export * as EventV2 from "./event"
+4 -5
View File
@@ -4,10 +4,9 @@ import { Catalog } from "./catalog"
import { PluginBoot } from "./plugin/boot"
export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("@opencode/example/LocationServiceMap", {
lookup: (ref: Location.Ref) =>
Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(
Layer.provide([Layer.succeed(Location.Service, Location.Service.of(ref))]),
),
lookup: (ref: Location.Ref) => {
const location = Layer.succeed(Location.Service, Location.Service.of(ref))
return Layer.mergeAll(Catalog.defaultLayer, PluginBoot.defaultLayer).pipe(Layer.provide(location))
},
idleTimeToLive: "5 minutes",
dependencies: [],
}) {}
+1 -12
View File
@@ -7,7 +7,6 @@ import { Flock } from "./util/flock"
import { Hash } from "./util/hash"
import { AppFileSystem } from "./filesystem"
import { InstallationChannel, InstallationVersion } from "./installation/version"
import { EventV2 } from "./event"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
@@ -106,13 +105,6 @@ export const Provider = Schema.Struct({
export type Provider = Schema.Schema.Type<typeof Provider>
export const Event = {
Refreshed: EventV2.define({
type: "models-dev.refreshed",
schema: {},
}),
}
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
export interface Interface {
@@ -126,7 +118,6 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const events = yield* EventV2.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
@@ -206,7 +197,6 @@ export const layer = Layer.effect(
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* events.publish(Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) =>
@@ -225,10 +215,9 @@ export const layer = Layer.effect(
}),
)
export const defaultLayer = layer.pipe(
export const defaultLayer: Layer.Layer<Service> = layer.pipe(
Layer.provide(FetchHttpClient.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
export * as ModelsDev from "./models-dev"
-45
View File
@@ -1,45 +0,0 @@
export * as PermissionV2 from "./permission"
import { Schema } from "effect"
import { Wildcard } from "./util/wildcard"
export const Action = Schema.Literals(["allow", "deny", "ask"]).annotate({ identifier: "PermissionV2.Action" })
export type Action = typeof Action.Type
export const Rule = Schema.Struct({
permission: Schema.String,
pattern: Schema.String,
action: Action,
}).annotate({ identifier: "PermissionV2.Rule" })
export type Rule = typeof Rule.Type
export const Ruleset = Schema.Array(Rule).annotate({ identifier: "PermissionV2.Ruleset" })
export type Ruleset = typeof Ruleset.Type
const EDIT_TOOLS = ["edit", "write", "apply_patch"]
export function evaluate(permission: string, pattern: string, ...rulesets: Ruleset[]): Rule {
return (
rulesets
.flat()
.findLast((rule) => Wildcard.match(permission, rule.permission) && Wildcard.match(pattern, rule.pattern)) ?? {
action: "ask",
permission,
pattern: "*",
}
)
}
export function merge(...rulesets: Ruleset[]): Ruleset {
return rulesets.flat()
}
export function disabled(tools: string[], ruleset: Ruleset): Set<string> {
return new Set(
tools.filter((tool) => {
const permission = EDIT_TOOLS.includes(tool) ? "edit" : tool
const rule = ruleset.findLast((rule) => Wildcard.match(permission, rule.permission))
return rule?.pattern === "*" && rule.action === "deny"
}),
)
}
+18 -63
View File
@@ -2,26 +2,27 @@ export * as PluginV2 from "./plugin"
import { createDraft, finishDraft, type Draft } from "immer"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { Context, Effect, Exit, Layer, PubSub, Schema, Scope, Stream } from "effect"
import { type ProviderV2 } from "./provider"
import { Context, Effect, Layer, Schema } from "effect"
import type { ModelV2 } from "./model"
import type { AgentV2 } from "./agent"
import type { Catalog } from "./catalog"
export const ID = Schema.String.pipe(Schema.brand("Plugin.ID"))
export type ID = typeof ID.Type
type HookSpec = {
"catalog.transform": {
input: Catalog.Context
output: {}
"provider.update": {
input: {}
output: {
provider: ProviderV2.Info
cancel: boolean
}
}
"account.switched": {
input: {
serviceID: import("./account").AccountV2.ServiceID
from?: import("./account").AccountV2.ID
to?: import("./account").AccountV2.ID
"model.update": {
input: {}
output: {
model: ModelV2.Info
cancel: boolean
}
output: {}
}
"aisdk.language": {
input: {
@@ -43,27 +44,6 @@ type HookSpec = {
sdk?: any
}
}
"agent.update": {
input: {}
output: {
agent: AgentV2.Info
cancel: boolean
}
}
"agent.remove": {
input: {
agent: AgentV2.Info
}
output: {
cancel: boolean
}
}
"agent.default": {
input: {}
output: {
agent?: AgentV2.ID
}
}
}
export type Hooks = {
@@ -81,25 +61,15 @@ export type HookFunctions = {
export type HookInput<Name extends keyof Hooks> = HookSpec[Name]["input"]
export type HookOutput<Name extends keyof Hooks> = HookSpec[Name]["output"]
export type Effect<R = never> = Effect.Effect<HookFunctions | void, never, R | Scope.Scope>
export type Effect = Effect.Effect<HookFunctions | void, never, never>
export function define<R>(input: { id: ID; effect: Effect.Effect<HookFunctions | void, never, R> }) {
return input
}
export interface Interface {
readonly add: (input: {
id: ID
effect: Effect.Effect<void | HookFunctions, never, Scope.Scope>
}) => Effect.Effect<void, never, never>
readonly add: (input: { id: ID; effect: Effect }) => Effect.Effect<void>
readonly remove: (id: ID) => Effect.Effect<void>
readonly added: () => Stream.Stream<ID>
readonly triggerFor: <Name extends keyof Hooks>(
id: ID,
name: Name,
input: HookInput<Name>,
output: HookOutput<Name>,
) => Effect.Effect<HookInput<Name> & HookOutput<Name>>
readonly trigger: <Name extends keyof Hooks>(
name: Name,
input: HookInput<Name>,
@@ -115,33 +85,21 @@ export const layer = Layer.effect(
let hooks: {
id: ID
hooks: HookFunctions
scope: Scope.Closeable
}[] = []
const added = yield* PubSub.unbounded<ID>()
yield* Effect.addFinalizer(() => PubSub.shutdown(added))
const svc = Service.of({
add: Effect.fn("Plugin.add")(function* (input) {
const existing = hooks.find((item) => item.id === input.id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
const scope = yield* Scope.make()
const result = yield* input.effect.pipe(Scope.provide(scope))
const result = yield* input.effect
if (!result) return
hooks = [
...hooks.filter((item) => item.id !== input.id),
{
id: input.id,
hooks: result ?? {},
scope,
hooks: result,
},
]
yield* PubSub.publish(added, input.id)
}),
added: () => Stream.fromPubSub(added),
trigger: Effect.fn("Plugin.trigger")(function* (name, input, output) {
return yield* svc.triggerFor(ID.make("*"), name, input, output)
}),
triggerFor: Effect.fn("Plugin.triggerFor")(function* (id, name, input, output) {
const draftEntries = new Map<string, ReturnType<typeof createDraft>>()
const event = {
...input,
@@ -156,7 +114,6 @@ export const layer = Layer.effect(
}
for (const item of hooks) {
if (id !== ID.make("*") && item.id !== id) continue
const match = item.hooks[name]
if (!match) continue
yield* match(event as any).pipe(
@@ -176,9 +133,7 @@ export const layer = Layer.effect(
return event as any
}),
remove: Effect.fn("Plugin.remove")(function* (id) {
const existing = hooks.find((item) => item.id === id)
hooks = hooks.filter((item) => item.id !== id)
if (existing) yield* Scope.close(existing.scope, Exit.void).pipe(Effect.ignore)
}),
})
return svc
-41
View File
@@ -1,41 +0,0 @@
import { Effect, Scope, Stream } from "effect"
import { AccountV2 } from "../account"
import { EventV2 } from "../event"
import { PluginV2 } from "../plugin"
export const AccountPlugin = PluginV2.define({
id: PluginV2.ID.make("account"),
effect: Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
yield* events.subscribe(AccountV2.Event.Switched).pipe(
Stream.runForEach((event) =>
PluginV2.Service.use((plugin) => plugin.trigger("account.switched", event.data, {})).pipe(Effect.asVoid),
),
Effect.forkIn(scope, { startImmediately: true }),
)
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
const account = yield* accounts.active(AccountV2.ServiceID.make(item.provider.id)).pipe(Effect.orDie)
if (!account) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "account",
service: account.serviceID,
}
if (account.credential.type === "api") {
provider.options.aisdk.provider.apiKey = account.credential.key
Object.assign(provider.options.aisdk.provider, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") provider.options.aisdk.provider.apiKey = account.credential.access
})
}
}),
"account.switched": Effect.fn(function* () {}),
}
}),
})
+27
View File
@@ -0,0 +1,27 @@
import { Effect } from "effect"
import { AuthV2 } from "../auth"
import { PluginV2 } from "../plugin"
export const AuthPlugin = PluginV2.define({
id: PluginV2.ID.make("auth"),
effect: Effect.gen(function* () {
const auth = yield* AuthV2.Service
return {
"provider.update": Effect.fn(function* (evt) {
const account = yield* auth.active(AuthV2.ServiceID.make(evt.provider.id)).pipe(Effect.orDie)
if (!account) return
evt.provider.enabled = {
via: "auth",
service: account.serviceID,
}
if (account.credential.type === "api") {
evt.provider.options.aisdk.provider.apiKey = account.credential.key
Object.assign(evt.provider.options.aisdk.provider, account.credential.metadata ?? {})
}
if (account.credential.type === "oauth") {
evt.provider.options.aisdk.provider.apiKey = account.credential.access
}
}),
}
}),
})
+41 -51
View File
@@ -1,22 +1,18 @@
export * as PluginBoot from "./boot"
import { Context, Deferred, Effect, Layer, Scope } from "effect"
import { AccountV2 } from "../account"
import { AgentV2 } from "../agent"
import { Context, Deferred, Effect, Layer } from "effect"
import { AuthV2 } from "../auth"
import { Catalog } from "../catalog"
import { EventV2 } from "../event"
import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { AccountPlugin } from "./account"
import { AuthPlugin } from "./auth"
import { EnvPlugin } from "./env"
import { ModelsDevPlugin } from "./models-dev"
import { ProviderPlugins } from "./provider"
type Plugin = {
id: PluginV2.ID
effect: PluginV2.Effect<
Catalog.Service | AgentV2.Service | AccountV2.Service | Npm.Service | EventV2.Service | PluginV2.Service
>
effect: Effect.Effect<PluginV2.HookFunctions | void, never, Catalog.Service | AuthV2.Service | Npm.Service>
}
export interface Interface {
@@ -25,57 +21,51 @@ export interface Interface {
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/PluginBoot") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const agent = yield* AgentV2.Service
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const accounts = yield* AccountV2.Service
const npm = yield* Npm.Service
const events = yield* EventV2.Service
const done = yield* Deferred.make<void>()
export const layer: Layer.Layer<Service, never, Catalog.Service | PluginV2.Service | AuthV2.Service | Npm.Service> =
Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const auth = yield* AuthV2.Service
const npm = yield* Npm.Service
const done = yield* Deferred.make<void>()
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(AgentV2.Service, agent),
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Npm.Service, npm),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
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),
),
})
})
})
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AccountPlugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
yield* add(ModelsDevPlugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
const boot = Effect.gen(function* () {
yield* add(EnvPlugin)
yield* add(AuthPlugin)
for (const item of ProviderPlugins) {
yield* add(item)
}
yield* add(ModelsDevPlugin)
}).pipe(Effect.withSpan("PluginBoot.boot"))
yield* boot.pipe(
Effect.exit,
Effect.flatMap((exit) => Deferred.done(done, exit)),
Effect.forkScoped,
)
yield* boot.pipe(
Effect.exit,
Effect.flatMap((exit) => Deferred.done(done, exit)),
Effect.forkScoped,
)
return Service.of({
wait: () => Deferred.await(done),
})
}),
)
return Service.of({
wait: () => Deferred.await(done),
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(AgentV2.defaultLayer),
Layer.provide(Catalog.defaultLayer),
Layer.provide(EventV2.defaultLayer),
Layer.provide(PluginV2.defaultLayer),
Layer.provide(AccountV2.defaultLayer),
Layer.provide(Layer.orDie(AuthV2.defaultLayer)),
Layer.provide(Npm.defaultLayer),
)
+6 -10
View File
@@ -5,16 +5,12 @@ export const EnvPlugin = PluginV2.define({
id: PluginV2.ID.make("env"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
const key = item.provider.env.find((env) => process.env[env])
if (!key) continue
evt.provider.update(item.provider.id, (provider) => {
provider.enabled = {
via: "env",
name: key,
}
})
"provider.update": Effect.fn(function* (evt) {
const key = evt.provider.env.find((item) => process.env[item])
if (!key) return
evt.provider.enabled = {
via: "env",
name: key,
}
}),
}
+44 -56
View File
@@ -1,6 +1,5 @@
import { DateTime, Effect, Scope, Stream } from "effect"
import { DateTime, Effect } from "effect"
import { Catalog } from "../catalog"
import { EventV2 } from "../event"
import { ModelV2 } from "../model"
import { ModelsDev } from "../models-dev"
import { PluginV2 } from "../plugin"
@@ -55,66 +54,55 @@ export const ModelsDevPlugin = PluginV2.define({
effect: Effect.gen(function* () {
const catalog = yield* Catalog.Service
const modelsDev = yield* ModelsDev.Service
const events = yield* EventV2.Service
const scope = yield* Scope.Scope
const load = yield* catalog.loader()
const refresh = Effect.fn("ModelsDevPlugin.refresh")(function* () {
const data = yield* modelsDev.get()
yield* load((catalog) => {
for (const item of Object.values(data)) {
const providerID = ProviderV2.ID.make(item.id)
catalog.provider.update(providerID, (provider) => {
provider.name = item.name
provider.env = [...item.env]
provider.endpoint = item.npm
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: item.npm,
url: item.api,
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,
}
})
for (const model of Object.values(item.models)) {
const modelID = ModelV2.ID.make(model.id)
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,
}
})
}
}
})
})
yield* refresh()
yield* events.subscribe(ModelsDev.Event.Refreshed).pipe(
Stream.runForEach(() => refresh()),
Effect.forkIn(scope, { startImmediately: true }),
)
.pipe(Effect.orDie)
}
}
}).pipe(Effect.provide(ModelsDev.defaultLayer)),
})
@@ -50,19 +50,14 @@ export const AmazonBedrockPlugin = PluginV2.define({
id: PluginV2.ID.make("amazon-bedrock"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/amazon-bedrock") continue
evt.provider.update(item.provider.id, (provider) => {
if (provider.endpoint.type !== "aisdk") return
if (typeof provider.options.aisdk.provider.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
provider.endpoint.url = provider.options.aisdk.provider.endpoint
delete provider.options.aisdk.provider.endpoint
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.amazonBedrock) return
if (evt.provider.endpoint.type !== "aisdk") return
if (typeof evt.provider.options.aisdk.provider.endpoint !== "string") return
// The AI SDK expects a base URL, but users configure Bedrock private/VPC
// endpoints as `endpoint`; move it into the catalog endpoint URL once.
evt.provider.endpoint.url = evt.provider.options.aisdk.provider.endpoint
delete evt.provider.options.aisdk.provider.endpoint
}),
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/amazon-bedrock") return
@@ -1,19 +1,15 @@
import { Effect } from "effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const AnthropicPlugin = PluginV2.define({
id: PluginV2.ID.make("anthropic"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/anthropic") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["anthropic-beta"] =
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.anthropic) return
evt.provider.options.headers["anthropic-beta"] =
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14"
}),
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/anthropic") return
+10 -22
View File
@@ -14,18 +14,12 @@ export const AzurePlugin = PluginV2.define({
id: PluginV2.ID.make("azure"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/azure") continue
const configured = item.provider.options.aisdk.provider.resourceName
const resourceName =
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
if (!resourceName) continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.aisdk.provider.resourceName = resourceName
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.azure) return
const configured = evt.provider.options.aisdk.provider.resourceName
const resourceName =
typeof configured === "string" && configured.trim() !== "" ? configured : process.env.AZURE_RESOURCE_NAME
if (resourceName) evt.provider.options.aisdk.provider.resourceName = resourceName
}),
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/azure") return
@@ -55,17 +49,11 @@ export const AzureCognitiveServicesPlugin = PluginV2.define({
id: PluginV2.ID.make("azure-cognitive-services"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("azure-cognitive-services")) return
const resourceName = process.env.AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
if (!resourceName) return
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (!item.provider.id.includes("azure-cognitive-services")) continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
})
}
if (resourceName)
evt.provider.options.aisdk.provider.baseURL = `https://${resourceName}.cognitiveservices.azure.com/openai`
}),
"aisdk.language": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.make("azure-cognitive-services")) return
@@ -1,18 +1,14 @@
import { Effect } from "effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const CerebrasPlugin = PluginV2.define({
id: PluginV2.ID.make("cerebras"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (ctx) {
for (const item of ctx.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/cerebras") continue
ctx.provider.update(item.provider.id, (provider) => {
provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("cerebras")) return
evt.provider.options.headers["X-Cerebras-3rd-Party-Integration"] = "opencode"
}),
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/cerebras") return
@@ -45,7 +45,7 @@ const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
function gatewayConfig(options: Record<string, unknown>): GatewayConfig | undefined {
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID ?? stringOption(options, "accountId")
// AccountPlugin copies CLI prompt metadata into options. The prompt stores the
// AuthPlugin copies CLI prompt metadata into options. The prompt stores the
// gateway as gatewayId, while older config examples may use gateway.
const gatewayId =
process.env.CLOUDFLARE_GATEWAY_ID ?? stringOption(options, "gatewayId") ?? stringOption(options, "gateway")
@@ -10,15 +10,13 @@ export const CloudflareWorkersAIPlugin = PluginV2.define({
id: PluginV2.ID.make("cloudflare-workers-ai"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.data.find((record) => record.provider.id === providerID)
if (!item) return
evt.provider.update(item.provider.id, (provider) => {
if (provider.endpoint.type !== "aisdk") return
if (provider.endpoint.url) return
const accountId = resolveAccountId(provider.options.aisdk.provider)
if (accountId) provider.endpoint.url = workersEndpoint(accountId)
})
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== providerID) return
if (evt.provider.endpoint.type !== "aisdk") return
if (evt.provider.endpoint.url) return
const accountId = resolveAccountId(evt.provider.options.aisdk.provider)
if (accountId) evt.provider.endpoint.url = workersEndpoint(accountId)
}),
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.model.providerID !== providerID) return
@@ -15,6 +15,9 @@ 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("../../github-copilot/copilot-provider"))
@@ -30,14 +33,11 @@ export const GithubCopilotPlugin = PluginV2.define({
? evt.sdk.responses(evt.model.apiID)
: evt.sdk.chat(evt.model.apiID)
}),
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.githubCopilot)
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
// so hide it only for Copilot rather than for every provider catalog.
model.enabled = false
})
"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
}),
}
}),
@@ -57,26 +57,20 @@ export const GoogleVertexPlugin = PluginV2.define({
id: PluginV2.ID.make("google-vertex"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (
item.provider.endpoint.package !== "@ai-sdk/google-vertex" &&
!item.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
)
continue
const project = resolveProject(item.provider.options.aisdk.provider)
const location = String(resolveLocation(item.provider.options.aisdk.provider))
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.options.aisdk.provider.project = project
provider.options.aisdk.provider.location = location
if (provider.endpoint.type === "aisdk" && provider.endpoint.url) {
provider.endpoint.url = replaceVertexVars(provider.endpoint.url, project, location)
}
if (provider.endpoint.type === "aisdk" && provider.endpoint.package.includes("@ai-sdk/openai-compatible")) {
provider.options.aisdk.provider.fetch = authFetch(provider.options.aisdk.provider.fetch)
}
})
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.googleVertex) return
const project = resolveProject(evt.provider.options.aisdk.provider)
const location = String(resolveLocation(evt.provider.options.aisdk.provider))
if (project) evt.provider.options.aisdk.provider.project = project
evt.provider.options.aisdk.provider.location = location
if (evt.provider.endpoint.type === "aisdk" && evt.provider.endpoint.url) {
evt.provider.endpoint.url = replaceVertexVars(evt.provider.endpoint.url, project, location)
}
if (
evt.provider.endpoint.type === "aisdk" &&
evt.provider.endpoint.package.includes("@ai-sdk/openai-compatible")
) {
evt.provider.options.aisdk.provider.fetch = authFetch(evt.provider.options.aisdk.provider.fetch)
}
}),
"aisdk.sdk": Effect.fn(function* (evt) {
@@ -108,25 +102,20 @@ export const GoogleVertexAnthropicPlugin = PluginV2.define({
id: PluginV2.ID.make("google-vertex-anthropic"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/google-vertex/anthropic") continue
const project =
item.provider.options.aisdk.provider.project ??
process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT
const location =
item.provider.options.aisdk.provider.location ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"global"
evt.provider.update(item.provider.id, (provider) => {
if (project) provider.options.aisdk.provider.project = project
provider.options.aisdk.provider.location = location
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("google-vertex-anthropic")) return
const project =
evt.provider.options.aisdk.provider.project ??
process.env.GOOGLE_CLOUD_PROJECT ??
process.env.GCP_PROJECT ??
process.env.GCLOUD_PROJECT
const location =
evt.provider.options.aisdk.provider.location ??
process.env.GOOGLE_CLOUD_LOCATION ??
process.env.VERTEX_LOCATION ??
"global"
if (project) evt.provider.options.aisdk.provider.project = project
evt.provider.options.aisdk.provider.location = location
}),
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/google-vertex/anthropic") return
+5 -10
View File
@@ -1,20 +1,15 @@
import { Effect } from "effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const KiloPlugin = PluginV2.define({
id: PluginV2.ID.make("kilo"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://api.kilo.ai/api/gateway") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("kilo")) return
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
evt.provider.options.headers["X-Title"] = "opencode"
}),
}
}),
@@ -1,22 +1,17 @@
import { Effect } from "effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const LLMGatewayPlugin = PluginV2.define({
id: PluginV2.ID.make("llmgateway"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.enabled === false) continue
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://api.llmgateway.io/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
provider.options.headers["X-Source"] = "opencode"
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("llmgateway")) return
if (evt.provider.enabled === false) return
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
evt.provider.options.headers["X-Title"] = "opencode"
evt.provider.options.headers["X-Source"] = "opencode"
}),
}
}),
+6 -11
View File
@@ -1,21 +1,16 @@
import { Effect } from "effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const NvidiaPlugin = PluginV2.define({
id: PluginV2.ID.make("nvidia"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://integrate.api.nvidia.com/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("nvidia")) return
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
evt.provider.options.headers["X-Title"] = "opencode"
evt.provider.options.headers["X-BILLING-INVOKE-ORIGIN"] ??= "OpenCode"
}),
}
}),
+5 -11
View File
@@ -16,17 +16,11 @@ export const OpenAIPlugin = PluginV2.define({
if (evt.model.providerID !== ProviderV2.ID.openai) return
evt.language = evt.sdk.responses(evt.model.apiID)
}),
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai") continue
if (!item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) continue
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so hide it only from OpenAI's catalog.
model.enabled = false
})
}
"model.update": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openai) return
// OpenAIPlugin sends OpenAI models through Responses; this alias is a
// chat-completions-only model, so remove it only from OpenAI's catalog.
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
}),
}
}),
+10 -15
View File
@@ -7,25 +7,20 @@ export const OpencodePlugin = PluginV2.define({
effect: Effect.gen(function* () {
let hasKey = false
return {
"catalog.transform": Effect.fn(function* (evt) {
const item = evt.data.find((record) => record.provider.id === ProviderV2.ID.opencode)
if (!item) return
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.opencode) return
hasKey = Boolean(
process.env.OPENCODE_API_KEY ||
item.provider.env.some((env) => process.env[env]) ||
item.provider.options.aisdk.provider.apiKey ||
(item.provider.enabled && item.provider.enabled.via === "account"),
evt.provider.env.some((item) => process.env[item]) ||
evt.provider.options.aisdk.provider.apiKey ||
(evt.provider.enabled && evt.provider.enabled.via === "auth"),
)
evt.provider.update(item.provider.id, (provider) => {
if (!hasKey) provider.options.aisdk.provider.apiKey = "public"
})
if (!hasKey) evt.provider.options.aisdk.provider.apiKey = "public"
}),
"model.update": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.opencode) return
if (hasKey) return
for (const model of item.models.values()) {
if (!model.cost.some((cost) => cost.input > 0)) continue
evt.model.update(item.provider.id, model.id, (draft) => {
draft.enabled = false
})
}
if (evt.model.cost.some((item) => item.input > 0)) evt.cancel = true
}),
}
}),
+12 -17
View File
@@ -1,34 +1,29 @@
import { Effect } from "effect"
import { ModelV2 } from "../../model"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const OpenRouterPlugin = PluginV2.define({
id: PluginV2.ID.make("openrouter"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@openrouter/ai-sdk-provider") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
provider.options.headers["X-Title"] = "opencode"
})
for (const modelID of [ModelV2.ID.make("gpt-5-chat-latest"), ModelV2.ID.make("openai/gpt-5-chat")]) {
if (!item.models.has(modelID)) continue
evt.model.update(item.provider.id, modelID, (model) => {
// These are OpenRouter-specific OpenAI chat aliases that do not work
// on the generic path. Keep custom providers with matching IDs untouched.
model.enabled = false
})
}
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.openrouter) return
evt.provider.options.headers["HTTP-Referer"] = "https://opencode.ai/"
evt.provider.options.headers["X-Title"] = "opencode"
}),
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@openrouter/ai-sdk-provider") return
const mod = yield* Effect.promise(() => import("@openrouter/ai-sdk-provider"))
evt.sdk = mod.createOpenRouter(evt.options)
}),
"model.update": Effect.fn(function* (evt) {
if (evt.model.providerID !== ProviderV2.ID.openrouter) return
// These are OpenRouter-specific OpenAI chat aliases that do not work on
// the generic path. Keep custom providers with matching IDs untouched.
if (evt.model.id === ModelV2.ID.make("gpt-5-chat-latest")) evt.cancel = true
if (evt.model.id === ModelV2.ID.make("openai/gpt-5-chat")) evt.cancel = true
}),
}
}),
})
+4 -9
View File
@@ -6,15 +6,10 @@ export const VercelPlugin = PluginV2.define({
id: PluginV2.ID.make("vercel"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/vercel") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["http-referer"] = "https://opencode.ai/"
provider.options.headers["x-title"] = "opencode"
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("vercel")) return
evt.provider.options.headers["http-referer"] = "https://opencode.ai/"
evt.provider.options.headers["x-title"] = "opencode"
}),
"aisdk.sdk": Effect.fn(function* (evt) {
if (evt.package !== "@ai-sdk/vercel") return
+5 -10
View File
@@ -1,20 +1,15 @@
import { Effect } from "effect"
import { PluginV2 } from "../../plugin"
import { ProviderV2 } from "../../provider"
export const ZenmuxPlugin = PluginV2.define({
id: PluginV2.ID.make("zenmux"),
effect: Effect.gen(function* () {
return {
"catalog.transform": Effect.fn(function* (evt) {
for (const item of evt.data) {
if (item.provider.endpoint.type !== "aisdk") continue
if (item.provider.endpoint.package !== "@ai-sdk/openai-compatible") continue
if (item.provider.endpoint.url !== "https://zenmux.ai/api/v1") continue
evt.provider.update(item.provider.id, (provider) => {
provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/"
provider.options.headers["X-Title"] ??= "opencode"
})
}
"provider.update": Effect.fn(function* (evt) {
if (evt.provider.id !== ProviderV2.ID.make("zenmux")) return
evt.provider.options.headers["HTTP-Referer"] ??= "https://opencode.ai/"
evt.provider.options.headers["X-Title"] ??= "opencode"
}),
}
}),
+1 -1
View File
@@ -86,7 +86,7 @@ export class Info extends Schema.Class<Info>("ProviderV2.Info")({
name: Schema.String,
}),
Schema.Struct({
via: Schema.Literal("account"),
via: Schema.Literal("auth"),
service: Schema.String,
}),
Schema.Struct({
-14
View File
@@ -1,14 +0,0 @@
export * as Wildcard from "./wildcard"
export function match(input: string, pattern: string) {
const normalized = input.replaceAll("\\", "/")
let escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
if (escaped.endsWith(" .*")) escaped = escaped.slice(0, -3) + "( .*)?"
return new RegExp("^" + escaped + "$", process.platform === "win32" ? "si" : "s").test(normalized)
}
-284
View File
@@ -1,284 +0,0 @@
import path from "path"
import { describe, expect } from "bun:test"
import { produce } from "immer"
import { Effect, Fiber, Layer, Option, Stream } from "effect"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Global } from "@opencode-ai/core/global"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { ModelV2 } from "@opencode-ai/core/model"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(PluginV2.defaultLayer)
function context(
records: { provider: ProviderV2.Info; models: Map<ModelV2.ID, ModelV2.Info> }[],
updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }>,
): Catalog.Context {
return {
data: records,
updateProvider: (providerID, fn) => context(records, updates).provider.update(providerID, fn),
updateModel: (providerID, modelID, fn) => context(records, updates).model.update(providerID, modelID, fn),
provider: {
update: (providerID, fn) => {
const record = records.find((item) => item.provider.id === providerID)
const provider = produce(record?.provider ?? ProviderV2.Info.empty(providerID), fn)
if (record) record.provider = provider
else records.push({ provider, models: new Map<ModelV2.ID, ModelV2.Info>() })
updates.push({
id: providerID,
enabled: provider.enabled,
apiKey:
typeof provider.options.aisdk.provider.apiKey === "string"
? provider.options.aisdk.provider.apiKey
: undefined,
})
},
remove: (providerID) => {
const index = records.findIndex((item) => item.provider.id === providerID)
if (index !== -1) records.splice(index, 1)
},
},
model: {
update: () => {},
remove: () => {},
},
}
}
function testLayer(dir: string) {
return AccountV2.layer.pipe(
Layer.provide(AppFileSystem.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provide(
Global.layerWith({
data: dir,
cache: path.join(dir, "cache"),
config: path.join(dir, "config"),
state: path.join(dir, "state"),
tmp: path.join(dir, "tmp"),
bin: path.join(dir, "bin"),
log: path.join(dir, "log"),
repos: path.join(dir, "repos"),
}),
),
)
}
describe("AccountV2", () => {
it.live("emits account lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const eventSvc = yield* EventV2.Service
const addedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Added)
.pipe(Stream.take(2), Stream.runCollect, Effect.forkScoped)
const switchedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
const removedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Removed)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "raw-key" }),
})
expect(first).toBeDefined()
if (!first) return
expect(first.description).toBe("default")
expect(first.credential.type).toBe("api")
if (first.credential.type === "api") expect(first.credential.key).toBe("raw-key")
yield* accounts.update(first.id, { description: "keep" })
const updated = yield* accounts.get(first.id)
expect(updated?.description).toBe("keep")
expect(updated?.credential.type).toBe("api")
if (updated?.credential.type === "api") expect(updated.credential.key).toBe("raw-key")
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* accounts.remove(second.id)
const added = Array.from(yield* Fiber.join(addedFiber))
const switched = Array.from(yield* Fiber.join(switchedFiber))
const removed = Array.from(yield* Fiber.join(removedFiber))
expect(added.map((event) => event.data.account.id)).toEqual([first.id, second.id])
expect(switched.map((event) => event.data)).toEqual([
{ serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: first.id },
])
expect(removed[0]?.data.account.id).toBe(second.id)
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("always switches to newly created accounts", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const eventSvc = yield* EventV2.Service
const switchedFiber = yield* eventSvc
.subscribe(AccountV2.Event.Switched)
.pipe(Stream.take(3), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
})
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
})
const third = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "third-key" }),
})
expect(first).toBeDefined()
expect(second).toBeDefined()
expect(third).toBeDefined()
if (!first || !second || !third) return
expect((yield* accounts.active(AccountV2.ServiceID.make("provider")))?.id).toBe(third.id)
expect(Array.from(yield* Fiber.join(switchedFiber)).map((event) => event.data)).toEqual([
{ serviceID: AccountV2.ServiceID.make("provider"), from: undefined, to: first.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: first.id, to: second.id },
{ serviceID: AccountV2.ServiceID.make("provider"), from: second.id, to: third.id },
])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
it.live("account plugin refreshes providers on account lifecycle events", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const accounts = yield* AccountV2.Service
const plugin = yield* PluginV2.Service
const records = [
{
provider: ProviderV2.Info.empty(ProviderV2.ID.make("provider")),
models: new Map<ModelV2.ID, ModelV2.Info>(),
},
]
const updates: Array<{ id: ProviderV2.ID; enabled: ProviderV2.Info["enabled"]; apiKey?: string }> = []
const catalog = Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.die("unexpected model.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
})
const eventSvc = yield* EventV2.Service
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, eventSvc),
Effect.provideService(PluginV2.Service, plugin),
),
})
yield* Effect.yieldNow
const first = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "first-key" }),
})
expect(first).toBeDefined()
if (!first) return
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "first-key",
},
])
updates.length = 0
const second = yield* accounts.create({
serviceID: AccountV2.ServiceID.make("provider"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "second-key" }),
})
expect(second).toBeDefined()
if (!second) return
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "second-key",
},
])
updates.length = 0
yield* accounts.activate(first.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "first-key",
},
])
updates.length = 0
yield* accounts.remove(first.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([
{
id: ProviderV2.ID.make("provider"),
enabled: { via: "account", service: AccountV2.ServiceID.make("provider") },
apiKey: "second-key",
},
])
updates.length = 0
yield* accounts.remove(second.id)
yield* plugin.trigger("catalog.transform", context(records, updates), {})
expect(updates).toEqual([])
}).pipe(Effect.provide(testLayer(tmp.path))),
),
),
)
})
+117 -124
View File
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { DateTime, Effect, Layer, Option } from "effect"
import { DateTime, Effect, Fiber, Layer, Option, Stream } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
@@ -22,24 +22,24 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://default.example.com",
}
provider.options.aisdk.provider.baseURL = "https://override.example.com"
}),
)
yield* catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://default.example.com",
}
provider.options.aisdk.provider.baseURL = "https://override.example.com"
})
expect((yield* catalog.provider.get(providerID)).endpoint).toEqual({
const provider = yield* catalog.provider.get(providerID)
expect(provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://override.example.com",
})
expect(provider.options.aisdk.provider.baseURL).toBeUndefined()
}),
)
@@ -48,27 +48,56 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com",
}
})
catalog.model.update(providerID, modelID, (model) => {
model.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://model.example.com" }
model.options.aisdk.provider.baseURL = "https://override.example.com"
})
yield* catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com",
}
})
yield* catalog.model.update(providerID, modelID, (model) => {
model.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://model.example.com",
}
model.options.aisdk.provider.baseURL = "https://override.example.com"
})
expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
const model = yield* catalog.model.get(providerID, modelID)
expect(model.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://override.example.com",
})
expect(model.options.aisdk.provider.baseURL).toBeUndefined()
}),
)
it.effect("publishes model updated events", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const fiber = yield* events
.subscribe(Catalog.Event.ModelUpdated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, modelID, (model) => {
model.name = "Updated Model"
})
const event = Array.from(yield* Fiber.join(fiber))[0]
expect(event?.type).toBe("catalog.model.updated")
expect(event?.data.model.providerID).toBe(providerID)
expect(event?.data.model.id).toBe(modelID)
expect(event?.data.model.name).toBe("Updated Model")
expect(event?.location).toEqual({ directory: "test" })
}),
)
@@ -77,20 +106,19 @@ describe("CatalogV2", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com",
}
})
catalog.model.update(providerID, modelID, () => {})
yield* catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com",
}
})
yield* catalog.model.update(providerID, modelID, () => {})
expect((yield* catalog.model.get(providerID, modelID)).endpoint).toEqual({
const model = yield* catalog.model.get(providerID, modelID)
expect(model.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://provider.example.com",
@@ -98,91 +126,58 @@ describe("CatalogV2", () => {
}),
)
it.effect("runs catalog transform hooks after baseURL is normalized", () =>
it.effect("runs provider hooks after baseURL is normalized", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const seen: unknown[] = []
const load = yield* catalog.loader()
yield* plugin.add({
id: PluginV2.ID.make("test"),
effect: Effect.succeed({
"catalog.transform": (evt) =>
"provider.update": (evt) =>
Effect.sync(() => {
const item = evt.data.find((record) => record.provider.id === providerID)
if (!item) return
seen.push(item.provider.endpoint.type)
if (item?.provider.endpoint.type === "aisdk") seen.push(item.provider.endpoint.url)
seen.push(item?.provider.options.aisdk.provider.baseURL)
seen.push(evt.provider.endpoint.type)
if (evt.provider.endpoint.type === "aisdk") seen.push(evt.provider.endpoint.url)
seen.push(evt.provider.options.aisdk.provider.baseURL)
}),
}),
})
yield* load((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
provider.options.aisdk.provider.baseURL = "https://provider.example.com"
}),
)
yield* catalog.provider.update(providerID, (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
}
provider.options.aisdk.provider.baseURL = "https://provider.example.com"
})
expect(seen).toEqual(["aisdk", "https://provider.example.com", undefined])
}),
)
it.effect("runs catalog transform when a plugin is added", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const plugin = yield* PluginV2.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(providerID, (provider) => {
provider.name = "Before"
}),
)
yield* plugin.add({
id: PluginV2.ID.make("test-transform"),
effect: Effect.succeed({
"catalog.transform": (evt) =>
Effect.sync(() =>
evt.provider.update(providerID, (provider) => {
provider.name = "After"
}),
),
}),
})
yield* Effect.yieldNow
expect((yield* catalog.provider.get(providerID)).name).toBe("After")
}),
)
it.effect("resolves provider and model option merges", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const modelID = ModelV2.ID.make("model")
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.options.headers.provider = "provider"
provider.options.headers.shared = "provider"
provider.options.body.provider = true
provider.options.aisdk.provider.provider = true
})
catalog.model.update(providerID, modelID, (model) => {
model.options.headers.model = "model"
model.options.headers.shared = "model"
model.options.body.model = true
model.options.aisdk.provider.model = true
model.options.aisdk.request.request = true
})
yield* catalog.provider.update(providerID, (provider) => {
provider.options.headers.provider = "provider"
provider.options.headers.shared = "provider"
provider.options.body.provider = true
provider.options.aisdk.provider.provider = true
})
yield* catalog.model.update(providerID, modelID, (model) => {
model.options.headers.model = "model"
model.options.headers.shared = "model"
model.options.body.model = true
model.options.aisdk.provider.model = true
model.options.aisdk.request.request = true
})
const model = yield* catalog.model.get(providerID, modelID)
expect(model.options.headers).toEqual({ provider: "provider", shared: "model", model: "model" })
expect(model.options.body).toEqual({ provider: true, model: true })
expect(model.options.aisdk.provider).toEqual({ provider: true, model: true })
@@ -194,21 +189,20 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
model.time.released = DateTime.makeUnsafe(2000)
})
yield* catalog.provider.update(providerID, (provider) => {
provider.enabled = { via: "custom", data: {} }
})
yield* catalog.model.update(providerID, ModelV2.ID.make("old"), (model) => {
model.time.released = DateTime.makeUnsafe(1000)
})
yield* catalog.model.update(providerID, ModelV2.ID.make("new"), (model) => {
model.time.released = DateTime.makeUnsafe(2000)
})
expect(Option.getOrUndefined(yield* catalog.model.default())?.id).toMatch("new")
const model = yield* catalog.model.default()
expect(Option.getOrUndefined(model)?.id).toMatch("new")
}),
)
@@ -216,25 +210,24 @@ describe("CatalogV2", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.make("test")
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
model.time.released = DateTime.makeUnsafe(Date.now())
})
catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
model.time.released = DateTime.makeUnsafe(Date.now())
})
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-large"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 1, output: 1, cache: { read: 0, write: 0 } }]
model.time.released = DateTime.makeUnsafe(Date.now())
})
yield* catalog.model.update(providerID, ModelV2.ID.make("expensive-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [{ input: 10, output: 10, cache: { read: 0, write: 0 } }]
model.time.released = DateTime.makeUnsafe(Date.now())
})
expect(Option.getOrUndefined(yield* catalog.model.small(providerID))?.id).toMatch("expensive-mini")
const model = yield* catalog.model.small(providerID)
expect(Option.getOrUndefined(model)?.id).toMatch("expensive-mini")
}),
)
})
-2
View File
@@ -5,7 +5,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Global } from "@opencode-ai/core/global"
import { ModelsDev } from "@opencode-ai/core/models-dev"
import { EventV2 } from "@opencode-ai/core/event"
import { it } from "./lib/effect"
import { rm, writeFile, utimes, mkdir } from "fs/promises"
import path from "path"
@@ -93,7 +92,6 @@ const buildLayer = (state: Ref.Ref<MockState>) =>
Layer.fresh(ModelsDev.layer).pipe(
Layer.provide(Layer.succeed(HttpClient.HttpClient, makeMockClient(state))),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(EventV2.defaultLayer),
)
const writeCache = (data: object, mtimeMs?: number) =>
@@ -1,9 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AmazonBedrockPlugin } from "@opencode-ai/core/plugin/provider/amazon-bedrock"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
function bedrockBaseURL(sdk: unknown, modelID = "anthropic.claude-sonnet-4-5") {
@@ -22,30 +20,27 @@ describe("AmazonBedrockPlugin", () => {
it.effect("moves endpoint option to endpoint URL", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AmazonBedrockPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const bedrock = provider("amazon-bedrock", {
endpoint: { type: "aisdk", package: "@ai-sdk/amazon-bedrock" },
options: {
headers: {},
body: {},
aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
},
})
catalog.provider.update(bedrock.id, (item) => {
item.endpoint = bedrock.endpoint
item.options = bedrock.options
})
})
const result = yield* catalog.provider.get(ProviderV2.ID.amazonBedrock)
expect(result.endpoint).toEqual({
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("amazon-bedrock", {
options: {
headers: {},
body: {},
aisdk: { provider: { endpoint: "https://bedrock.example" }, request: {} },
},
}),
cancel: false,
},
)
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/amazon-bedrock",
package: "test-provider",
url: "https://bedrock.example",
})
expect(result.options.aisdk.provider.endpoint).toBeUndefined()
expect(result.provider.options.aisdk.provider.endpoint).toBeUndefined()
}),
)
@@ -1,43 +1,37 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AnthropicPlugin } from "@opencode-ai/core/plugin/provider/anthropic"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider } from "./provider-helper"
describe("AnthropicPlugin", () => {
it.effect("applies legacy beta headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("anthropic", {
endpoint: { type: "aisdk", package: "@ai-sdk/anthropic" },
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
draft.options = item.options
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers["anthropic-beta"]).toBe(
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("anthropic", {
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.headers["anthropic-beta"]).toBe(
"interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
)
expect((yield* catalog.provider.get(ProviderV2.ID.anthropic)).options.headers.Existing).toBe("1")
expect(result.provider.options.headers.Existing).toBe("1")
}),
)
it.effect("ignores non-Anthropic providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(provider("openai").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.headers["anthropic-beta"]).toBeUndefined()
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
expect(result.provider.options.headers["anthropic-beta"]).toBeUndefined()
}),
)
@@ -1,9 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AzureCognitiveServicesPlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
describe("AzureCognitiveServicesPlugin", () => {
@@ -11,22 +9,20 @@ describe("AzureCognitiveServicesPlugin", () => {
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: "cognitive" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.make("azure-cognitive-services"), (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/openai-compatible" }
})
})
const result = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
expect(result.endpoint).toEqual({
const result = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("azure-cognitive-services"), cancel: false },
)
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://cognitive.cognitiveservices.azure.com/openai",
package: "test-provider",
})
expect(result.options.aisdk.provider.baseURL).toBeUndefined()
expect(result.options.aisdk.provider.resourceName).toBeUndefined()
expect(result.provider.options.aisdk.provider.baseURL).toBe(
"https://cognitive.cognitiveservices.azure.com/openai",
)
expect(result.provider.options.aisdk.provider.resourceName).toBeUndefined()
}),
),
)
@@ -35,27 +31,17 @@ describe("AzureCognitiveServicesPlugin", () => {
withEnv({ AZURE_COGNITIVE_SERVICES_RESOURCE_NAME: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzureCognitiveServicesPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const azure = provider("azure-cognitive-services", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible" },
})
const openai = provider("openai")
catalog.provider.update(azure.id, (item) => {
item.endpoint = azure.endpoint
})
catalog.provider.update(openai.id, (item) => {
item.endpoint = openai.endpoint
})
})
const azure = yield* catalog.provider.get(ProviderV2.ID.make("azure-cognitive-services"))
const openai = yield* catalog.provider.get(ProviderV2.ID.openai)
expect(azure.options.aisdk.provider.baseURL).toBeUndefined()
expect(azure.endpoint).toEqual({ type: "aisdk", package: "@ai-sdk/openai-compatible" })
expect(openai.options.aisdk.provider.baseURL).toBeUndefined()
expect(openai.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
const azure = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("azure-cognitive-services"), cancel: false },
)
const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
expect(azure.provider.options.aisdk.provider.baseURL).toBeUndefined()
expect(azure.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
expect(other.provider.options.aisdk.provider.baseURL).toBeUndefined()
expect(other.provider.endpoint).toEqual({ type: "aisdk", package: "test-provider" })
}),
),
)
@@ -1,40 +1,22 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { AzurePlugin } from "@opencode-ai/core/plugin/provider/azure"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
),
)
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
describe("AzurePlugin", () => {
it.effect("resolves resourceName from env", () =>
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false })
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
}),
),
)
@@ -43,29 +25,25 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const azure = provider("azure", {
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
})
catalog.provider.update(azure.id, (item) => {
item.endpoint = azure.endpoint
item.options = azure.options
})
catalog.provider.update(ProviderV2.ID.openai, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
"from-config",
const azure = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("azure", {
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "from-config" }, request: {} } },
}),
cancel: false,
},
)
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.resourceName).toBeUndefined()
const other = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
expect(azure.provider.options.aisdk.provider.resourceName).toBe("from-config")
expect(other.provider.options.aisdk.provider.resourceName).toBeUndefined()
}),
),
)
itWithAccount.effect("prefers account resourceName over env", () =>
itWithAuth.effect("prefers auth resourceName over env", () =>
withEnv(
{
AZURE_RESOURCE_NAME: "from-env",
@@ -73,36 +51,23 @@ describe("AzurePlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const accounts = yield* AccountV2.Service
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
yield* accounts.create({
serviceID: AccountV2.ServiceID.make("azure"),
credential: new AccountV2.ApiKeyCredential({
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("azure"),
credential: new AuthV2.ApiKeyCredential({
type: "api",
key: "key",
metadata: { resourceName: "from-account" },
metadata: { resourceName: "from-auth" },
}),
active: true,
})
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
})
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.azure, (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/azure" }
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe(
"from-account",
)
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("azure"), cancel: false })
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-auth")
}),
),
)
@@ -111,20 +76,18 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const azure = provider("azure", {
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
})
catalog.provider.update(azure.id, (item) => {
item.endpoint = azure.endpoint
item.options = azure.options
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("azure", {
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: "" }, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
}),
),
)
@@ -133,20 +96,18 @@ describe("AzurePlugin", () => {
withEnv({ AZURE_RESOURCE_NAME: "from-env" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(AzurePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const azure = provider("azure", {
endpoint: { type: "aisdk", package: "@ai-sdk/azure" },
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },
})
catalog.provider.update(azure.id, (item) => {
item.endpoint = azure.endpoint
item.options = azure.options
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.azure)).options.aisdk.provider.resourceName).toBe("from-env")
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("azure", {
options: { headers: {}, body: {}, aisdk: { provider: { resourceName: " " }, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.aisdk.provider.resourceName).toBe("from-env")
}),
),
)
@@ -1,9 +1,7 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { CerebrasPlugin } from "@opencode-ai/core/plugin/provider/cerebras"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider } from "./provider-helper"
const cerebrasOptions: Record<string, unknown>[] = []
@@ -22,30 +20,27 @@ describe("CerebrasPlugin", () => {
it.effect("applies the legacy integration header", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.make("cerebras"), (item) => {
item.endpoint = { type: "aisdk", package: "@ai-sdk/cerebras" }
item.options.headers.Existing = "1"
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("cerebras"))).options.headers).toEqual({
Existing: "1",
"X-Cerebras-3rd-Party-Integration": "opencode",
})
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("cerebras", {
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({ Existing: "1", "X-Cerebras-3rd-Party-Integration": "opencode" })
}),
)
it.effect("ignores non-Cerebras providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CerebrasPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("groq"), () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("groq"))).options.headers).toEqual({})
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("groq"), cancel: false })
expect(result.provider.options.headers).toEqual({})
}),
)
@@ -1,26 +1,14 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { Location } from "@opencode-ai/core/location"
import { EventV2 } from "@opencode-ai/core/event"
import { AuthV2 } from "@opencode-ai/core/auth"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { CloudflareWorkersAIPlugin } from "@opencode-ai/core/plugin/provider/cloudflare-workers-ai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { fakeSelectorSdk, it, model, npmLayer, withEnv } from "./provider-helper"
import { fakeSelectorSdk, it, model, npmLayer, provider, withEnv } from "./provider-helper"
const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
),
)
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
function cloudflareLanguage(sdk: unknown, modelID = "@cf/model") {
return (sdk as { languageModel: (id: string) => { config: CloudflareConfig; provider: string } }).languageModel(
@@ -46,25 +34,22 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct", CLOUDFLARE_API_KEY: "key" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
const updated = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("cloudflare-workers-ai"), cancel: false },
)
const provider = yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))
const sdk = yield* plugin.trigger(
"aisdk.sdk",
{
model: model("cloudflare-workers-ai", "@cf/model", { endpoint: provider.endpoint }),
model: model("cloudflare-workers-ai", "@cf/model", { endpoint: updated.provider.endpoint }),
package: "@ai-sdk/openai-compatible",
options: { name: "cloudflare-workers-ai", headers: { custom: "header" } },
},
{},
)
expect(provider.endpoint).toEqual({
expect(updated.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
@@ -78,15 +63,18 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "acct" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" }
}),
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("cloudflare-workers-ai", {
endpoint: { type: "aisdk", package: "test-provider", url: "https://proxy.example/v1" },
}),
cancel: false,
},
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://proxy.example/v1",
@@ -116,7 +104,7 @@ describe("CloudflareWorkersAIPlugin", () => {
),
)
itWithAccount.effect("falls back to account metadata when account env is absent", () =>
itWithAuth.effect("falls back to auth account metadata when account env is absent", () =>
withEnv(
{
CLOUDFLARE_ACCOUNT_ID: undefined,
@@ -125,37 +113,30 @@ describe("CloudflareWorkersAIPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const accounts = yield* AccountV2.Service
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
yield* accounts.create({
serviceID: AccountV2.ServiceID.make("cloudflare-workers-ai"),
credential: new AccountV2.ApiKeyCredential({
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("cloudflare-workers-ai"),
credential: new AuthV2.ApiKeyCredential({
type: "api",
key: "account-key",
metadata: { accountId: "account-acct" },
key: "auth-key",
metadata: { accountId: "auth-acct" },
}),
active: true,
})
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
})
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider" }
}),
const updated = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("cloudflare-workers-ai"), cancel: false },
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
expect(updated.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/account-acct/ai/v1",
url: "https://api.cloudflare.com/client/v4/accounts/auth-acct/ai/v1",
})
}),
),
@@ -165,16 +146,18 @@ describe("CloudflareWorkersAIPlugin", () => {
withEnv({ CLOUDFLARE_ACCOUNT_ID: "env-acct" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(CloudflareWorkersAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("cloudflare-workers-ai"), (provider) => {
provider.endpoint = { type: "aisdk", package: "test-provider" }
provider.options.aisdk.provider.accountId = "configured-acct"
}),
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("cloudflare-workers-ai", {
options: { headers: {}, body: {}, aisdk: { provider: { accountId: "configured-acct" }, request: {} } },
}),
cancel: false,
},
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("cloudflare-workers-ai"))).endpoint).toEqual({
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "test-provider",
url: "https://api.cloudflare.com/client/v4/accounts/env-acct/ai/v1",
@@ -1,10 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("GithubCopilotPlugin", () => {
@@ -147,35 +145,29 @@ describe("GithubCopilotPlugin", () => {
}),
)
it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
it.effect("filters gpt-5-chat-latest before Copilot language selection", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
expect(
(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(false)
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("github-copilot", "gpt-5-chat-latest"), cancel: false },
)
expect(result.cancel).toBe(true)
}),
)
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
it.effect("does not filter gpt-5-chat-latest for non-Copilot providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GithubCopilotPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
expect(
(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(true)
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("custom-copilot", "gpt-5-chat-latest"), cancel: false },
)
expect(result.cancel).toBe(false)
}),
)
@@ -1,15 +1,11 @@
import { describe, expect, mock } from "bun:test"
import { Effect, Layer } from "effect"
import { AccountV2 } from "@opencode-ai/core/account"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { AuthV2 } from "@opencode-ai/core/auth"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { AccountPlugin } from "@opencode-ai/core/plugin/account"
import { AuthPlugin } from "@opencode-ai/core/plugin/auth"
import { GitLabPlugin } from "@opencode-ai/core/plugin/provider/gitlab"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
import { it, model, npmLayer, withEnv } from "./provider-helper"
import { it, model, npmLayer, provider, withEnv } from "./provider-helper"
const gitlabSDKOptions: Record<string, unknown>[] = []
@@ -26,15 +22,7 @@ void mock.module("gitlab-ai-provider", () => ({
isWorkflowModel: (id: string) => id === "duo-workflow" || id === "duo-workflow-exact",
}))
const itWithAccount = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(AccountV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))),
Layer.provideMerge(npmLayer),
),
)
const itWithAuth = testEffect(Layer.mergeAll(PluginV2.defaultLayer, AuthV2.defaultLayer, npmLayer))
describe("GitLabPlugin", () => {
it.effect("creates SDKs with legacy default instance URL, token env, headers, and feature flags", () =>
@@ -153,7 +141,7 @@ describe("GitLabPlugin", () => {
}),
)
itWithAccount.effect("uses active account API token over GITLAB_TOKEN", () =>
itWithAuth.effect("uses active API auth token over GITLAB_TOKEN", () =>
withEnv(
{
GITLAB_TOKEN: "env-token",
@@ -162,41 +150,33 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const accounts = yield* AccountV2.Service
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
yield* accounts.create({
serviceID: AccountV2.ServiceID.make("gitlab"),
credential: new AccountV2.ApiKeyCredential({ type: "api", key: "account-token" }),
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("gitlab"),
credential: new AuthV2.ApiKeyCredential({ type: "api", key: "auth-token" }),
active: true,
})
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
})
yield* plugin.add(GitLabPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false })
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
package: "gitlab-ai-provider",
options: provider.options.aisdk.provider,
options: updated.provider.options.aisdk.provider,
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("account-token")
expect(gitlabSDKOptions[0].apiKey).toBe("auth-token")
}),
),
)
itWithAccount.effect("uses active account OAuth access token when no API token exists", () =>
itWithAuth.effect("uses active OAuth access token when no API auth exists", () =>
withEnv(
{
GITLAB_TOKEN: undefined,
@@ -205,41 +185,33 @@ describe("GitLabPlugin", () => {
Effect.gen(function* () {
gitlabSDKOptions.length = 0
const plugin = yield* PluginV2.Service
const accounts = yield* AccountV2.Service
const catalog = yield* Catalog.Service
const events = yield* EventV2.Service
yield* accounts.create({
serviceID: AccountV2.ServiceID.make("gitlab"),
credential: new AccountV2.OAuthCredential({
const auth = yield* AuthV2.Service
yield* auth.create({
serviceID: AuthV2.ServiceID.make("gitlab"),
credential: new AuthV2.OAuthCredential({
type: "oauth",
refresh: "refresh-token",
access: "account-oauth-token",
access: "oauth-token",
expires: 9999999999999,
}),
active: true,
})
yield* plugin.add({
...AccountPlugin,
effect: AccountPlugin.effect.pipe(
Effect.provideService(AccountV2.Service, accounts),
Effect.provideService(Catalog.Service, catalog),
Effect.provideService(EventV2.Service, events),
Effect.provideService(PluginV2.Service, plugin),
),
...AuthPlugin,
effect: AuthPlugin.effect.pipe(Effect.provideService(AuthV2.Service, auth)),
})
yield* plugin.add(GitLabPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(ProviderV2.ID.make("gitlab"), () => {}))
const provider = yield* catalog.provider.get(ProviderV2.ID.make("gitlab"))
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("gitlab"), cancel: false })
yield* plugin.trigger(
"aisdk.sdk",
{
model: model("gitlab", "claude"),
package: "gitlab-ai-provider",
options: provider.options.aisdk.provider,
options: updated.provider.options.aisdk.provider,
},
{},
)
expect(gitlabSDKOptions[0].apiKey).toBe("account-oauth-token")
expect(gitlabSDKOptions[0].apiKey).toBe("oauth-token")
}),
),
)
@@ -1,10 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GoogleVertexAnthropicPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
describe("GoogleVertexAnthropicPlugin", () => {
it.effect("resolves legacy project and location env on provider update", () =>
@@ -20,17 +18,14 @@ describe("GoogleVertexAnthropicPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
}),
const result = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("google-vertex-anthropic"), cancel: false },
)
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
expect(provider.options.aisdk.provider.project).toBe("cloud-project")
expect(provider.options.aisdk.provider.location).toBe("cloud-location")
expect(result.provider.options.aisdk.provider.project).toBe("cloud-project")
expect(result.provider.options.aisdk.provider.location).toBe("cloud-location")
}),
),
)
@@ -39,19 +34,23 @@ describe("GoogleVertexAnthropicPlugin", () => {
withEnv({ GOOGLE_CLOUD_PROJECT: "env-project", GOOGLE_CLOUD_LOCATION: "env-location" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexAnthropicPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex-anthropic"), (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex/anthropic" }
provider.options.aisdk.provider.project = "configured-project"
provider.options.aisdk.provider.location = "configured-location"
}),
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex-anthropic", {
options: {
headers: {},
body: {},
aisdk: { provider: { project: "configured-project", location: "configured-location" }, request: {} },
},
}),
cancel: false,
},
)
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex-anthropic"))
expect(provider.options.aisdk.provider.project).toBe("configured-project")
expect(provider.options.aisdk.provider.location).toBe("configured-location")
expect(result.provider.options.aisdk.provider.project).toBe("configured-project")
expect(result.provider.options.aisdk.provider.location).toBe("configured-location")
}),
),
)
@@ -1,10 +1,8 @@
import { describe, expect, mock } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { GoogleVertexPlugin } from "@opencode-ai/core/plugin/provider/google-vertex"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, withEnv } from "./provider-helper"
import { fakeSelectorSdk, it, model, provider, withEnv } from "./provider-helper"
const vertexOptions: Record<string, any>[] = []
@@ -45,22 +43,24 @@ describe("GoogleVertexPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
}
}),
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex", {
endpoint: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
},
}),
cancel: false,
},
)
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(provider.options.aisdk.provider.project).toBe("google-cloud-project")
expect(provider.options.aisdk.provider.location).toBe("google-vertex-location")
expect(provider.endpoint).toEqual({
expect(result.provider.options.aisdk.provider.project).toBe("google-cloud-project")
expect(result.provider.options.aisdk.provider.location).toBe("google-vertex-location")
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://google-vertex-location-aiplatform.googleapis.com/v1/projects/google-cloud-project/locations/google-vertex-location",
@@ -84,19 +84,21 @@ describe("GoogleVertexPlugin", () => {
Effect.gen(function* () {
vertexOptions.length = 0
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
}
}),
const updated = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex", {
endpoint: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
},
}),
cancel: false,
},
)
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
yield* plugin.trigger(
"aisdk.sdk",
{
@@ -109,8 +111,8 @@ describe("GoogleVertexPlugin", () => {
{},
)
expect(provider.options.aisdk.provider.project).toBe("vertex-project")
expect(provider.endpoint).toEqual({
expect(updated.provider.options.aisdk.provider.project).toBe("vertex-project")
expect(updated.provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://europe-west4-aiplatform.googleapis.com/v1/projects/vertex-project/locations/europe-west4",
@@ -134,24 +136,29 @@ describe("GoogleVertexPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
}
provider.options.aisdk.provider.project = "config-project"
provider.options.aisdk.provider.location = "global"
}),
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex", {
endpoint: {
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://${GOOGLE_VERTEX_ENDPOINT}/v1/projects/${GOOGLE_VERTEX_PROJECT}/locations/${GOOGLE_VERTEX_LOCATION}",
},
options: {
headers: {},
body: {},
aisdk: { provider: { project: "config-project", location: "global" }, request: {} },
},
}),
cancel: false,
},
)
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(provider.options.aisdk.provider.project).toBe("config-project")
expect(provider.options.aisdk.provider.location).toBe("global")
expect(provider.endpoint).toEqual({
expect(result.provider.options.aisdk.provider.project).toBe("config-project")
expect(result.provider.options.aisdk.provider.location).toBe("global")
expect(result.provider.endpoint).toEqual({
type: "aisdk",
package: "@ai-sdk/openai-compatible",
url: "https://aiplatform.googleapis.com/v1/projects/config-project/locations/global",
@@ -173,18 +180,19 @@ describe("GoogleVertexPlugin", () => {
() =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(GoogleVertexPlugin)
const load = yield* catalog.loader()
yield* load((catalog) =>
catalog.provider.update(ProviderV2.ID.make("google-vertex"), (provider) => {
provider.endpoint = { type: "aisdk", package: "@ai-sdk/google-vertex" }
provider.options.aisdk.provider.project = "config-project"
}),
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("google-vertex", {
options: { headers: {}, body: {}, aisdk: { provider: { project: "config-project" }, request: {} } },
}),
cancel: false,
},
)
const provider = yield* catalog.provider.get(ProviderV2.ID.make("google-vertex"))
expect(provider.options.aisdk.provider.project).toBe("config-project")
expect(provider.options.aisdk.provider.location).toBe("us-central1")
expect(result.provider.options.aisdk.provider.project).toBe("config-project")
expect(result.provider.options.aisdk.provider.location).toBe("us-central1")
}),
),
)
+1 -32
View File
@@ -2,16 +2,12 @@ import { Npm } from "@opencode-ai/core/npm"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { expect } from "bun:test"
import { Effect, Layer, Option } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { EventV2 } from "@opencode-ai/core/event"
import { Location } from "@opencode-ai/core/location"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "../lib/effect"
export const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.url).href
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
export const npmLayer = Layer.succeed(
Npm.Service,
@@ -22,34 +18,7 @@ export const npmLayer = Layer.succeed(
}),
)
export const catalogLayer = Layer.succeed(
Catalog.Service,
Catalog.Service.of({
loader: () => Effect.die("unexpected catalog.loader"),
provider: {
get: () => Effect.die("unexpected provider.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
},
model: {
get: () => Effect.die("unexpected model.get"),
all: () => Effect.succeed([]),
available: () => Effect.succeed([]),
default: () => Effect.succeed(Option.none<ModelV2.Info>()),
setDefault: () => Effect.die("unexpected model.setDefault"),
small: () => Effect.succeed(Option.none<ModelV2.Info>()),
},
}),
)
export const it = testEffect(
Catalog.layer.pipe(
Layer.provideMerge(PluginV2.defaultLayer),
Layer.provideMerge(EventV2.defaultLayer),
Layer.provideMerge(locationLayer),
Layer.provideMerge(npmLayer),
),
)
export const it = testEffect(Layer.mergeAll(PluginV2.defaultLayer, npmLayer))
export function provider(providerID: string, options?: Partial<ProviderV2.Info>) {
return new ProviderV2.Info({
+40 -50
View File
@@ -1,10 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { KiloPlugin } from "@opencode-ai/core/plugin/provider/kilo"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("KiloPlugin", () => {
@@ -20,81 +18,73 @@ describe("KiloPlugin", () => {
it.effect("applies legacy referer headers only to kilo", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const kilo = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
})
catalog.provider.update(kilo.id, (draft) => {
draft.endpoint = kilo.endpoint
draft.options = kilo.options
})
catalog.provider.update(provider("openrouter").id, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("kilo", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
expect(ignored.provider.options.headers).toEqual({})
}),
)
it.effect("uses the exact legacy Kilo header casing and set", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("kilo"), cancel: false })
const result = yield* catalog.provider.get(ProviderV2.ID.make("kilo"))
expect(result.options.headers).toEqual({
expect(result.provider.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect(result.options.headers).not.toHaveProperty("http-referer")
expect(result.options.headers).not.toHaveProperty("x-title")
expect(result.options.headers).not.toHaveProperty("X-Source")
expect(result.provider.options.headers).not.toHaveProperty("http-referer")
expect(result.provider.options.headers).not.toHaveProperty("x-title")
expect(result.provider.options.headers).not.toHaveProperty("X-Source")
}),
)
it.effect("uses the legacy provider-id guard instead of endpoint package matching", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(KiloPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const kilo = provider("kilo", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.kilo.ai/api/gateway" },
})
catalog.provider.update(kilo.id, (draft) => {
draft.endpoint = kilo.endpoint
})
const custom = provider("custom-kilo", {
endpoint: { type: "aisdk", package: "kilo" },
})
catalog.provider.update(custom.id, (draft) => {
draft.endpoint = custom.endpoint
})
})
const matchingID = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("kilo", {
endpoint: { type: "aisdk", package: "not-kilo" },
}),
cancel: false,
},
)
const matchingPackage = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("custom-kilo", {
endpoint: { type: "aisdk", package: "kilo" },
}),
cancel: false,
},
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("kilo"))).options.headers).toEqual({
expect(matchingID.provider.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("custom-kilo"))).options.headers).toEqual({})
expect(matchingPackage.provider.options.headers).toEqual({})
}),
)
})
@@ -1,10 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { LLMGatewayPlugin } from "@opencode-ai/core/plugin/provider/llmgateway"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("LLMGatewayPlugin", () => {
@@ -20,54 +18,46 @@ describe("LLMGatewayPlugin", () => {
it.effect("applies legacy referer headers only to enabled llmgateway", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const llmgateway = provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
})
catalog.provider.update(llmgateway.id, (draft) => {
draft.enabled = llmgateway.enabled
draft.endpoint = llmgateway.endpoint
draft.options = llmgateway.options
})
const openrouter = provider("openrouter", {
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
})
catalog.provider.update(openrouter.id, (draft) => {
draft.enabled = openrouter.enabled
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("llmgateway", {
enabled: { via: "env", name: "LLMGATEWAY_API_KEY" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
const ignored = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("openrouter", {
enabled: { via: "env", name: "OPENROUTER_API_KEY" },
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-Source": "opencode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
expect(ignored.provider.options.headers).toEqual({})
}),
)
it.effect("does not apply legacy headers to a disabled llmgateway provider", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(LLMGatewayPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("llmgateway", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://api.llmgateway.io/v1" },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("llmgateway"), cancel: false })
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).enabled).toBe(false)
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).options.headers).toEqual({})
expect(result.provider.enabled).toBe(false)
expect(result.provider.options.headers).toEqual({})
}),
)
})
@@ -1,10 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { NvidiaPlugin } from "@opencode-ai/core/plugin/provider/nvidia"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("NvidiaPlugin", () => {
@@ -20,48 +18,45 @@ describe("NvidiaPlugin", () => {
it.effect("applies NVIDIA tracking headers only to nvidia", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const nvidia = provider("nvidia", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
})
catalog.provider.update(nvidia.id, (draft) => {
draft.endpoint = nvidia.endpoint
draft.options = nvidia.options
})
catalog.provider.update(provider("openrouter").id, () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("nvidia", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("openrouter"), cancel: false })
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({})
expect(ignored.provider.options.headers).toEqual({})
}),
)
it.effect("adds billing origin for custom NVIDIA endpoints", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("nvidia", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
draft.options = item.options
})
})
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("nvidia", {
endpoint: { type: "aisdk", package: "test-provider", url: "http://localhost:8000/v1" },
options: { headers: {}, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
expect(result.provider.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "OpenCode",
@@ -72,25 +67,23 @@ describe("NvidiaPlugin", () => {
it.effect("preserves an explicit NVIDIA billing origin header", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(NvidiaPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("nvidia", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://integrate.api.nvidia.com/v1" },
options: {
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
body: {},
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
},
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
draft.options = item.options
})
})
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("nvidia", {
options: {
headers: { "X-BILLING-INVOKE-ORIGIN": "CustomOrigin" },
body: {},
aisdk: { provider: { baseURL: "https://integrate.api.nvidia.com/v1" }, request: {} },
},
}),
cancel: false,
},
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({
expect(result.provider.options.headers).toEqual({
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
"X-BILLING-INVOKE-ORIGIN": "CustomOrigin",
@@ -1,11 +1,9 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { OpenAIPlugin } from "@opencode-ai/core/plugin/provider/openai"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { fakeSelectorSdk, it, model, provider } from "./provider-helper"
import { fakeSelectorSdk, it, model } from "./provider-helper"
describe("OpenAIPlugin", () => {
it.effect("creates an OpenAI SDK for @ai-sdk/openai using the provider ID as SDK name", () =>
@@ -72,39 +70,31 @@ describe("OpenAIPlugin", () => {
}),
)
it.effect("disables gpt-5-chat-latest during catalog transforms", () =>
it.effect("cancels gpt-5-chat-latest during model updates", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("openai", { endpoint: { type: "aisdk", package: "@ai-sdk/openai" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5"), () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5"))).enabled).toBe(true)
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("gpt-5-chat-latest"))).enabled).toBe(false)
const normal = yield* plugin.trigger("model.update", {}, { model: model("openai", "gpt-5"), cancel: false })
const filtered = yield* plugin.trigger(
"model.update",
{},
{ model: model("openai", "gpt-5-chat-latest"), cancel: false },
)
expect(normal.cancel).toBe(false)
expect(filtered.cancel).toBe(true)
}),
)
it.effect("does not disable gpt-5-chat-latest for non-OpenAI providers", () =>
it.effect("does not cancel gpt-5-chat-latest for non-OpenAI providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenAIPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("custom-openai")
catalog.provider.update(item.id, () => {})
catalog.model.update(item.id, ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
expect(
(yield* catalog.model.get(ProviderV2.ID.make("custom-openai"), ModelV2.ID.make("gpt-5-chat-latest"))).enabled,
).toBe(true)
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("custom-openai", "gpt-5-chat-latest"), cancel: false },
)
expect(result.cancel).toBe(false)
}),
)
})
@@ -12,23 +12,19 @@ const cost = (input: number, output = 0) => [{ input, output, cache: { read: 0,
const locationLayer = Layer.succeed(Location.Service, Location.Service.of({ directory: "test" }))
describe("OpencodePlugin", () => {
it.effect("uses a public key and disables paid models without credentials", () =>
it.effect("uses a public key and cancels paid models without credentials", () =>
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(false)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBe("public")
expect(paid.cancel).toBe(true)
}),
),
)
@@ -37,19 +33,14 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const free = model("opencode", "free", { cost: cost(0) })
catalog.model.update(item.id, free.id, (draft) => {
draft.cost = [...free.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("free"))).enabled).toBe(true)
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
const free = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "free", { cost: cost(0) }), cancel: false },
)
expect(free.cancel).toBe(false)
}),
),
)
@@ -58,19 +49,14 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const outputOnly = model("opencode", "output-only", { cost: cost(0, 1) })
catalog.model.update(item.id, outputOnly.id, (draft) => {
draft.cost = [...outputOnly.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("public")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("output-only"))).enabled).toBe(true)
yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
const outputOnly = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "output-only", { cost: cost(0, 1) }), cancel: false },
)
expect(outputOnly.cancel).toBe(false)
}),
),
)
@@ -79,19 +65,15 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("opencode")
catalog.provider.update(item.id, () => {})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("opencode"), cancel: false })
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
expect(paid.cancel).toBe(false)
}),
),
)
@@ -100,21 +82,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined, CUSTOM_OPENCODE_API_KEY: "secret" }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] })
catalog.provider.update(item.id, (draft) => {
draft.env = [...item.env]
})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
const updated = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("opencode", { env: ["CUSTOM_OPENCODE_API_KEY"] }), cancel: false },
)
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
expect(paid.cancel).toBe(false)
}),
),
)
@@ -123,30 +103,31 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("opencode", {
options: {
headers: {},
body: {},
aisdk: {
provider: { apiKey: "configured" },
request: {},
const updated = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("opencode", {
options: {
headers: {},
body: {},
aisdk: {
provider: { apiKey: "configured" },
request: {},
},
},
},
})
catalog.provider.update(item.id, (draft) => {
draft.options = item.options
})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBe("configured")
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
}),
cancel: false,
},
)
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBe("configured")
expect(paid.cancel).toBe(false)
}),
),
)
@@ -155,21 +136,19 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("opencode", { enabled: { via: "account", service: "opencode" } })
catalog.provider.update(item.id, (draft) => {
draft.enabled = item.enabled
})
const paid = model("opencode", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.opencode)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.opencode, ModelV2.ID.make("paid"))).enabled).toBe(true)
const updated = yield* plugin.trigger(
"provider.update",
{},
{ provider: provider("opencode", { enabled: { via: "auth", service: "opencode" } }), cancel: false },
)
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("opencode", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
expect(paid.cancel).toBe(false)
}),
),
)
@@ -178,19 +157,15 @@ describe("OpencodePlugin", () => {
withEnv({ OPENCODE_API_KEY: undefined }, () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpencodePlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("openai")
catalog.provider.update(item.id, () => {})
const paid = model("openai", "paid", { cost: cost(1) })
catalog.model.update(item.id, paid.id, (draft) => {
draft.cost = [...paid.cost]
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.openai)).options.aisdk.provider.apiKey).toBeUndefined()
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("paid"))).enabled).toBe(true)
const updated = yield* plugin.trigger("provider.update", {}, { provider: provider("openai"), cancel: false })
const paid = yield* plugin.trigger(
"model.update",
{},
{ model: model("openai", "paid", { cost: cost(1) }), cancel: false },
)
expect(updated.provider.options.aisdk.provider.apiKey).toBeUndefined()
expect(paid.cancel).toBe(false)
}),
),
)
@@ -200,21 +175,18 @@ describe("OpencodePlugin", () => {
const catalog = yield* Catalog.Service
const providerID = ProviderV2.ID.opencode
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(providerID, () => {})
catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [...cost(1, 1)]
model.time.released = DateTime.makeUnsafe(Date.now())
})
catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = [...cost(10, 10)]
model.time.released = DateTime.makeUnsafe(Date.now())
})
yield* catalog.provider.update(providerID, () => {})
yield* catalog.model.update(providerID, ModelV2.ID.make("cheap-mini"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = cost(1, 1)
model.time.released = DateTime.makeUnsafe(Date.now())
})
yield* catalog.model.update(providerID, ModelV2.ID.make("gpt-5-nano"), (model) => {
model.capabilities.input = ["text"]
model.capabilities.output = ["text"]
model.cost = cost(10, 10)
model.time.released = DateTime.makeUnsafe(Date.now())
})
const selected = yield* catalog.model.small(providerID)
@@ -1,11 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { ModelV2 } from "@opencode-ai/core/model"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { OpenRouterPlugin } from "@opencode-ai/core/plugin/provider/openrouter"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, model, provider } from "./provider-helper"
describe("OpenRouterPlugin", () => {
@@ -21,27 +18,24 @@ describe("OpenRouterPlugin", () => {
it.effect("applies legacy referer headers only to openrouter", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const openrouter = provider("openrouter", {
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
})
catalog.provider.update(openrouter.id, (item) => {
item.endpoint = openrouter.endpoint
item.options = openrouter.options
})
catalog.provider.update(ProviderV2.ID.make("nvidia"), () => {})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("openrouter"))).options.headers).toEqual({
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("openrouter", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
const ignored = yield* plugin.trigger("provider.update", {}, { provider: provider("nvidia"), cancel: false })
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("nvidia"))).options.headers).toEqual({})
expect(ignored.provider.options.headers).toEqual({})
}),
)
@@ -73,50 +67,39 @@ describe("OpenRouterPlugin", () => {
it.effect("filters OpenRouter's gpt-5 chat alias", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const openrouter = provider("openrouter", {
endpoint: { type: "aisdk", package: "@openrouter/ai-sdk-provider" },
})
catalog.provider.update(openrouter.id, (item) => {
item.endpoint = openrouter.endpoint
})
catalog.provider.update(ProviderV2.ID.openai, () => {})
for (const item of [
model("openrouter", "openai/gpt-5-chat"),
model("openrouter", "openai/gpt-5"),
model("openai", "openai/gpt-5-chat"),
]) {
catalog.model.update(item.providerID, item.id, () => {})
}
})
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("openrouter", "openai/gpt-5-chat"), cancel: false },
)
const regular = yield* plugin.trigger(
"model.update",
{},
{ model: model("openrouter", "openai/gpt-5"), cancel: false },
)
const ignored = yield* plugin.trigger(
"model.update",
{},
{ model: model("openai", "openai/gpt-5-chat"), cancel: false },
)
expect(
(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5-chat"))).enabled,
).toBe(false)
expect(
(yield* catalog.model.get(ProviderV2.ID.make("openrouter"), ModelV2.ID.make("openai/gpt-5"))).enabled,
).toBe(true)
expect((yield* catalog.model.get(ProviderV2.ID.openai, ModelV2.ID.make("openai/gpt-5-chat"))).enabled).toBe(true)
expect(result.cancel).toBe(true)
expect(regular.cancel).toBe(false)
expect(ignored.cancel).toBe(false)
}),
)
it.effect("does not disable gpt-5-chat-latest for non-OpenRouter providers", () =>
it.effect("does not filter gpt-5-chat-latest for non-OpenRouter providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(OpenRouterPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
catalog.provider.update(ProviderV2.ID.make("custom-openrouter"), () => {})
catalog.model.update(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
})
expect(
(yield* catalog.model.get(ProviderV2.ID.make("custom-openrouter"), ModelV2.ID.make("gpt-5-chat-latest")))
.enabled,
).toBe(true)
const result = yield* plugin.trigger(
"model.update",
{},
{ model: model("custom-openrouter", "gpt-5-chat-latest"), cancel: false },
)
expect(result.cancel).toBe(false)
}),
)
})
@@ -1,29 +1,25 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { VercelPlugin } from "@opencode-ai/core/plugin/provider/vercel"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { it, model, provider } from "./provider-helper"
describe("VercelPlugin", () => {
it.effect("applies legacy lower-case referer headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("vercel", {
endpoint: { type: "aisdk", package: "@ai-sdk/vercel" },
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
draft.options = item.options
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).toEqual({
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("vercel", {
options: { headers: { Existing: "1" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect(result.provider.options.headers).toEqual({
Existing: "1",
"http-referer": "https://opencode.ai/",
"x-title": "opencode",
@@ -34,19 +30,10 @@ describe("VercelPlugin", () => {
it.effect("does not add legacy upper-case referer headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("vercel", { endpoint: { type: "aisdk", package: "@ai-sdk/vercel" } })
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty(
"HTTP-Referer",
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("vercel"))).options.headers).not.toHaveProperty("X-Title")
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("vercel"), cancel: false })
expect(result.provider.options.headers).not.toHaveProperty("HTTP-Referer")
expect(result.provider.options.headers).not.toHaveProperty("X-Title")
}),
)
@@ -67,11 +54,9 @@ describe("VercelPlugin", () => {
it.effect("ignores non-Vercel providers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(VercelPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => catalog.provider.update(provider("gateway").id, () => {}))
expect((yield* catalog.provider.get(ProviderV2.ID.make("gateway"))).options.headers).toEqual({})
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("gateway"), cancel: false })
expect(result.provider.options.headers).toEqual({})
}),
)
})
@@ -1,10 +1,8 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
import { PluginV2 } from "@opencode-ai/core/plugin"
import { ProviderPlugins } from "@opencode-ai/core/plugin/provider"
import { ZenmuxPlugin } from "@opencode-ai/core/plugin/provider/zenmux"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { expectPluginRegistered, it, provider } from "./provider-helper"
describe("ZenmuxPlugin", () => {
@@ -20,41 +18,30 @@ describe("ZenmuxPlugin", () => {
it.effect("applies the exact legacy Zenmux headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("zenmux", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
})
})
const result = yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))
expect(result.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
expect(Object.keys(result.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
const result = yield* plugin.trigger("provider.update", {}, { provider: provider("zenmux"), cancel: false })
expect(result.provider.options.headers).toEqual({ "HTTP-Referer": "https://opencode.ai/", "X-Title": "opencode" })
expect(Object.keys(result.provider.options.headers).sort()).toEqual(["HTTP-Referer", "X-Title"])
expect(result.cancel).toBe(false)
}),
)
it.effect("merges legacy Zenmux headers with existing headers", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("zenmux", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
draft.options = item.options
})
})
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("zenmux", {
options: { headers: { Existing: "value" }, body: {}, aisdk: { provider: {}, request: {} } },
}),
cancel: false,
},
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
expect(result.provider.options.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
@@ -65,25 +52,23 @@ describe("ZenmuxPlugin", () => {
it.effect("lets configured Zenmux legacy headers override defaults", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("zenmux", {
endpoint: { type: "aisdk", package: "@ai-sdk/openai-compatible", url: "https://zenmux.ai/api/v1" },
options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
aisdk: { provider: {}, request: {} },
},
})
catalog.provider.update(item.id, (draft) => {
draft.endpoint = item.endpoint
draft.options = item.options
})
})
const result = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("zenmux", {
options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
aisdk: { provider: {}, request: {} },
},
}),
cancel: false,
},
)
expect((yield* catalog.provider.get(ProviderV2.ID.make("zenmux"))).options.headers).toEqual({
expect(result.provider.options.headers).toEqual({
"HTTP-Referer": "https://example.com/",
"X-Title": "custom-title",
})
@@ -93,23 +78,23 @@ describe("ZenmuxPlugin", () => {
it.effect("guards legacy Zenmux headers to the exact zenmux provider id", () =>
Effect.gen(function* () {
const plugin = yield* PluginV2.Service
const catalog = yield* Catalog.Service
yield* plugin.add(ZenmuxPlugin)
const load = yield* catalog.loader()
yield* load((catalog) => {
const item = provider("openrouter", {
options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
aisdk: { provider: {}, request: {} },
},
})
catalog.provider.update(item.id, (draft) => {
draft.options = item.options
})
})
const ignored = yield* plugin.trigger(
"provider.update",
{},
{
provider: provider("openrouter", {
options: {
headers: { "HTTP-Referer": "https://example.com/", "X-Title": "custom-title" },
body: {},
aisdk: { provider: {}, request: {} },
},
}),
cancel: false,
},
)
expect((yield* catalog.provider.get(ProviderV2.ID.openrouter)).options.headers).toEqual({
expect(ignored.provider.options.headers).toEqual({
"HTTP-Referer": "https://example.com/",
"X-Title": "custom-title",
})
+5 -3
View File
@@ -3,15 +3,14 @@ import { defineConfig } from "electron-vite"
import appPlugin from "@opencode-ai/app/vite"
import * as fs from "node:fs/promises"
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
if (process.env.OPENCODE_CHANNEL === "latest") return "prod"
return "dev"
})()
const OPENCODE_SERVER_DIST = "../opencode/dist/node"
const nodePtyPkg = `@lydell/node-pty-${process.platform}-${process.arch}`
const sentry =
@@ -83,6 +82,9 @@ export default defineConfig({
plugins: [appPlugin, sentry],
publicDir: "../../../app/public",
root: "src/renderer",
define: {
"import.meta.env.VITE_OPENCODE_CHANNEL": JSON.stringify(channel),
},
build: {
sourcemap: true,
rollupOptions: {
-1
View File
@@ -24,7 +24,6 @@
},
"main": "./out/main/index.js",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
"electron-context-menu": "4.1.2",
"electron-log": "^5",
+4 -33
View File
@@ -15,7 +15,7 @@ import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } fr
import { checkAppExists, resolveAppPath, wslPath } from "./apps"
import { CHANNEL, UPDATER_ENABLED } from "./constants"
import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
import { initLogging } from "./logging"
import { parseMarkdown } from "./markdown"
import { createMenu } from "./menu"
import {
@@ -31,7 +31,6 @@ import {
createLoadingWindow,
createMainWindow,
registerRendererProtocol,
setRelaunchHandler,
setBackgroundColor,
setDockIcon,
} from "./windows"
@@ -50,7 +49,6 @@ const APP_IDS: Record<string, string> = {
prod: "ai.opencode.desktop",
}
const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1"
const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports"
let logger: ReturnType<typeof initLogging>
let mainWindow: BrowserWindow | null = null
@@ -143,7 +141,6 @@ const main = Effect.gen(function* () {
)
if (onboardingTestRoot) app.setPath("sessionData", join(onboardingTestRoot, "session"))
logger = initLogging()
initCrashReporter()
try {
setDefaultCACertificates([...new Set([...getCACertificates("default"), ...getCACertificates("system")])])
@@ -160,8 +157,6 @@ const main = Effect.gen(function* () {
ensureLoopbackNoProxy()
useEnvProxy()
app.commandLine.appendSwitch("proxy-bypass-list", "<-loopback>")
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
if (!app.isPackaged) app.commandLine.appendSwitch("remote-debugging-port", "9222")
if (!app.requestSingleInstanceLock()) {
@@ -197,21 +192,6 @@ const main = Effect.gen(function* () {
void killSidecar()
})
app.on("child-process-gone", (_event, details) => {
writeLog("utility", "child process gone", { details }, "error")
})
app.on("render-process-gone", (_event, webContents, details) => {
writeLog("window", "app render process gone", { url: webContents.getURL(), details }, "error")
})
setRelaunchHandler(() => {
void killSidecar().finally(() => {
app.relaunch()
app.exit(0)
})
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void killSidecar().finally(() => app.exit(0))
@@ -256,8 +236,6 @@ const main = Effect.gen(function* () {
checkUpdate: async () => checkUpdate(),
installUpdate: async () => installUpdate(killSidecar),
setBackgroundColor: (color) => setBackgroundColor(color),
exportDebugLogs: () => exportDebugLogs(),
recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"),
})
yield* Effect.promise(() => app.whenReady())
@@ -267,13 +245,6 @@ const main = Effect.gen(function* () {
registerRendererProtocol()
setDockIcon()
setupAutoUpdater()
yield* Effect.promise(() => startNetLog()).pipe(
Effect.catch((error) =>
Effect.sync(() => {
logger.warn("failed to start net log", error)
}),
),
)
const needsMigration = ((): boolean => {
if (process.env.OPENCODE_DB === ":memory:") return false
@@ -329,9 +300,9 @@ const main = Effect.gen(function* () {
needsMigration,
userDataPath: app.getPath("userData"),
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
onStdout: (message) => writeLog("server", "stdout", { message }),
onStderr: (message) => writeLog("server", "stderr", { message }, "warn"),
onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"),
onStdout: (message) => logger.log("sidecar stdout", { message }),
onStderr: (message) => logger.warn("sidecar stderr", { message }),
onExit: (code) => logger.warn("sidecar exited", { code }),
}),
)
server = listener
-7
View File
@@ -5,7 +5,6 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
import type {
InitStep,
FatalRendererError,
ServerReadyData,
SqliteMigrationProgress,
TitlebarTheme,
@@ -41,8 +40,6 @@ type Deps = {
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void> | void
setBackgroundColor: (color: string) => void
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void
}
export function registerIpcHandlers(deps: Deps) {
@@ -74,10 +71,6 @@ export function registerIpcHandlers(deps: Deps) {
ipcMain.handle("check-update", () => deps.checkUpdate())
ipcMain.handle("install-update", () => deps.installUpdate())
ipcMain.handle("set-background-color", (_event: IpcMainInvokeEvent, color: string) => deps.setBackgroundColor(color))
ipcMain.handle("export-debug-logs", () => deps.exportDebugLogs())
ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) =>
deps.recordFatalRendererError(error),
)
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
try {
const store = getStore(name)
+5 -149
View File
@@ -1,92 +1,21 @@
import { MainLogger } from "electron-log"
import log from "electron-log/main.js"
import { app, crashReporter, netLog, shell } from "electron"
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"
import { ZipWriter, BlobWriter, BlobReader } from "@zip.js/zip.js"
import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs"
import { dirname, join } from "node:path"
import { homedir } from "node:os"
const MAX_LOG_AGE_DAYS = 7
const TAIL_LINES = 1000
const EXPORT_WINDOW = 24 * 60 * 60 * 1000
const MAX_EXPORT_FILE_SIZE = 50 * 1024 * 1024
const NET_LOG_SIZE = 20 * 1024 * 1024
let root = ""
let run = ""
let netLogPath: string | undefined
let logger: MainLogger
export const getLogger = () => logger
export function initLogging() {
initRunDirectory()
log.transports.file.maxSize = 5 * 1024 * 1024
log.transports.file.resolvePathFn = (_vars, message) =>
join(
run,
`${safeLogName(message?.scope ?? (message?.variables?.processType === "renderer" ? "renderer" : "main"))}.log`,
)
log.initialize({ preload: false, spyRendererConsole: true })
initConsoleTransport()
cleanup()
return (logger = log)
}
export function initCrashReporter() {
const dir = join(app.getPath("userData"), "Crashpad")
mkdirSync(dir, { recursive: true })
app.setPath("crashDumps", dir)
crashReporter.start({ uploadToServer: false, compress: true })
write("crash", "crash reporter started", { path: dir })
}
export async function startNetLog() {
if (netLog.currentlyLogging) return
netLogPath = join(run, "network.netlog")
await netLog.startLogging(netLogPath, { captureMode: "default", maxFileSize: NET_LOG_SIZE })
write("network", "net log started", { path: netLogPath })
}
export async function exportDebugLogs() {
const restartNetLog = netLog.currentlyLogging
if (restartNetLog) {
await netLog.stopLogging().catch((error) => write("network", "failed to stop net log", { error }))
}
const output = join(app.getPath("downloads"), `opencode-debug-${stamp()}.zip`)
try {
write("main", "exporting debug logs", { output })
await writeZip(output, [
{ name: "manifest.json", data: Buffer.from(JSON.stringify(manifest(), null, 2)) },
...collect(root, "desktop"),
...serverLogRoots().flatMap((dir, i) => collect(dir, `server-${i + 1}`)),
...collect(app.getPath("crashDumps"), "crashpad"),
])
shell.showItemInFolder(output)
return output
} finally {
if (restartNetLog) {
await startNetLog().catch((error) => write("network", "failed to restart net log", { error }))
}
}
}
export function write(
name: string,
message: string,
extra?: Record<string, unknown>,
level: "info" | "warn" | "error" = "info",
) {
if (!run) return
const scoped = log.scope(safeLogName(name))
if (extra !== undefined) {
scoped[level](message, extra)
return
}
scoped[level](message)
}
export function tail(): string {
try {
const path = log.transports.file.getFile().path
@@ -98,96 +27,23 @@ export function tail(): string {
}
}
function initRunDirectory() {
root = join(app.getPath("userData"), "logs")
run = join(root, stamp())
mkdirSync(run, { recursive: true })
}
function stamp() {
return new Date()
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d+Z$/, "")
}
function safeLogName(name: string) {
return name.replace(/[^a-z0-9_.-]/gi, "_") || "main"
}
function cleanup() {
const dir = root || dirname(log.transports.file.getFile().path)
const path = log.transports.file.getFile().path
const dir = dirname(path)
const cutoff = Date.now() - MAX_LOG_AGE_DAYS * 24 * 60 * 60 * 1000
for (const entry of readdirSync(dir)) {
const file = join(dir, entry)
try {
const info = statSync(file)
if (info.mtimeMs < cutoff) rmSync(file, { recursive: true, force: true })
if (!info.isFile()) continue
if (info.mtimeMs < cutoff) unlinkSync(file)
} catch {
continue
}
}
}
function manifest() {
return {
generated: new Date().toISOString(),
version: app.getVersion(),
name: app.getName(),
packaged: app.isPackaged,
platform: process.platform,
arch: process.arch,
versions: process.versions,
uptime: process.uptime(),
userData: app.getPath("userData"),
logs: root,
currentRun: run,
crashDumps: app.getPath("crashDumps"),
serverLogs: serverLogRoots(),
netLog: netLogPath,
}
}
function serverLogRoots() {
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
return [...new Set([join(xdgData, "opencode", "log"), join(app.getPath("userData"), "opencode", "log")])]
}
type Entry = { name: string; path?: string; data?: Buffer }
function collect(dir: string, prefix: string): Entry[] {
if (!existsSync(dir)) return []
const cutoff = Date.now() - EXPORT_WINDOW
const result: Entry[] = []
const walk = (current: string) => {
for (const entry of readdirSync(current)) {
const file = join(current, entry)
const info = statSync(file)
if (info.isDirectory()) {
walk(file)
continue
}
if (info.mtimeMs < cutoff) continue
if (info.size > MAX_EXPORT_FILE_SIZE) continue
if (file.endsWith(".heapsnapshot")) continue
result.push({ name: join(prefix, file.slice(dir.length + 1)).replace(/\\/g, "/"), path: file })
}
}
walk(dir)
return result
}
async function writeZip(output: string, entries: Entry[]) {
const writer = new ZipWriter(new BlobWriter("application/zip"))
for (const entry of entries) {
const data = entry.data ?? readFileSync(entry.path!)
await writer.add(entry.name, new BlobReader(new Blob([new Uint8Array(data)])))
}
const zip = await writer.close()
writeFileSync(output, Buffer.from(await zip.arrayBuffer()))
}
function initConsoleTransport() {
const write = log.transports.console.writeFn.bind(log.transports.console)
log.transports.console.writeFn = (options) => {
-69
View File
@@ -1,69 +0,0 @@
import type { BrowserWindow } from "electron"
import { write as writeLog } from "./logging"
const sampleInterval = 1000
const samplePeriod = 15000
export function createUnresponsiveSampler(win: BrowserWindow, name: string) {
let sampleTimer: ReturnType<typeof setTimeout> | undefined
let stopTimer: ReturnType<typeof setTimeout> | undefined
let sampling = false
const samples = new Map<string, number>()
const active = () => sampling && !win.isDestroyed() && !win.webContents.isDestroyed()
const clearTimers = () => {
if (sampleTimer) clearTimeout(sampleTimer)
if (stopTimer) clearTimeout(stopTimer)
sampleTimer = undefined
stopTimer = undefined
}
const schedule = () => {
sampleTimer = setTimeout(() => {
void collect()
}, sampleInterval)
}
const collect = async () => {
if (!active()) return
const stack = await win.webContents.mainFrame.collectJavaScriptCallStack().catch((error) => {
writeLog("window", "failed to collect unresponsive sample", { window: name, error }, "error")
return undefined
})
if (!active()) return
if (stack) samples.set(stack, (samples.get(stack) ?? 0) + 1)
schedule()
}
const stopAndFlush = () => {
const wasSampling = sampling
sampling = false
clearTimers()
if (samples.size === 0) return wasSampling
const entries = [...samples.entries()].sort((a, b) => b[1] - a[1])
const total = entries.reduce((sum, entry) => sum + entry[1], 0)
const message = [
"renderer unresponsive samples",
`Window: ${name}`,
`URL: ${win.isDestroyed() ? "<destroyed>" : win.webContents.getURL()}`,
...entries.map((entry) => `<${entry[1]}> ${entry[0]}`),
`Total Samples: ${total}`,
].join("\n")
writeLog("window", message, undefined, "error")
samples.clear()
return wasSampling
}
const start = () => {
if (sampling || win.isDestroyed() || win.webContents.isDestroyed() || win.webContents.isDevToolsOpened()) return
sampling = true
samples.clear()
schedule()
stopTimer = setTimeout(stopAndFlush, samplePeriod)
}
win.on("closed", stopAndFlush)
return { start, stopAndFlush }
}
+5 -22
View File
@@ -1,16 +1,13 @@
import { app, dialog } from "electron"
import pkg from "electron-updater"
import { UPDATER_ENABLED } from "./constants"
import { getLogger } from "./logging"
import { initLogging } from "./logging"
const logger = initLogging()
const { autoUpdater } = pkg
type UpdateCheckResult = { updateAvailable: boolean; version?: string; failed?: boolean }
let downloadedVersion: string | undefined
let pendingCheck: Promise<UpdateCheckResult> | undefined
export function setupAutoUpdater() {
if (!UPDATER_ENABLED) return
const logger = getLogger()
autoUpdater.logger = logger
autoUpdater.channel = "latest"
autoUpdater.allowPrerelease = false
@@ -25,19 +22,8 @@ export function setupAutoUpdater() {
})
}
export async function checkUpdate(): Promise<UpdateCheckResult> {
export async function checkUpdate() {
if (!UPDATER_ENABLED) return { updateAvailable: false }
if (downloadedVersion) return { updateAvailable: true, version: downloadedVersion }
if (pendingCheck) return pendingCheck
pendingCheck = checkAndDownloadUpdate().finally(() => {
pendingCheck = undefined
})
return pendingCheck
}
async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
const logger = getLogger()
logger.log("checking for updates", {
currentVersion: app.getVersion(),
channel: autoUpdater.channel,
@@ -62,7 +48,6 @@ async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
}
logger.log("update available", { version })
await autoUpdater.downloadUpdate()
downloadedVersion = version
logger.log("update download completed", { version })
return { updateAvailable: true, version }
} catch (error) {
@@ -72,9 +57,8 @@ async function checkAndDownloadUpdate(): Promise<UpdateCheckResult> {
}
export async function installUpdate(killSidecar: () => Promise<void>) {
const result = downloadedVersion ? { updateAvailable: true, version: downloadedVersion } : await checkUpdate()
const logger = getLogger()
if (!result.updateAvailable || !downloadedVersion) {
const result = await checkUpdate()
if (!result.updateAvailable) {
logger.log("install update skipped", {
reason: result.failed ? "update check failed" : "no update available",
})
@@ -89,7 +73,6 @@ export async function installUpdate(killSidecar: () => Promise<void>) {
export async function checkForUpdates(alertOnFail: boolean, killSidecar: () => Promise<void>) {
if (!UPDATER_ENABLED) return
const logger = getLogger()
logger.log("checkForUpdates invoked", { alertOnFail })
const result = await checkUpdate()
if (!result.updateAvailable) {
+5 -171
View File
@@ -1,10 +1,8 @@
import windowState from "electron-window-state"
import { app, BrowserWindow, dialog, net, nativeImage, nativeTheme, protocol } from "electron"
import { app, BrowserWindow, net, nativeImage, nativeTheme, protocol } from "electron"
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import type { TitlebarTheme } from "../preload/types"
import { exportDebugLogs, write as writeLog } from "./logging"
import { createUnresponsiveSampler } from "./unresponsive"
const root = dirname(fileURLToPath(import.meta.url))
const rendererRoot = join(root, "../renderer")
@@ -13,8 +11,6 @@ const rendererHost = "renderer"
const clipboardWritePermission = "clipboard-sanitized-write"
const notificationPermission = "notifications"
const rendererPermissions = new Set([clipboardWritePermission, notificationPermission])
const documentPolicyHeader = "Document-Policy"
const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports"
protocol.registerSchemesAsPrivileged([
{
@@ -28,17 +24,9 @@ protocol.registerSchemesAsPrivileged([
])
let backgroundColor: string | undefined
let relaunchHandler = () => {
app.relaunch()
app.exit(0)
}
const titlebarThemes = new WeakMap<BrowserWindow, Partial<TitlebarTheme>>()
const titlebarHeight = 40
export function setRelaunchHandler(handler: () => void) {
relaunchHandler = handler
}
export function setBackgroundColor(color: string) {
backgroundColor = color
}
@@ -124,7 +112,6 @@ export function createMainWindow() {
})
allowRendererPermissions(win)
wireWindowRecovery(win, "main")
win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => {
const { requestHeaders } = details
@@ -134,7 +121,8 @@ export function createMainWindow() {
win.webContents.session.webRequest.onHeadersReceived((details, callback) => {
const { responseHeaders = {} } = details
addRendererHeaders(details.url, responseHeaders)
upsertKeyValue(responseHeaders, "Access-Control-Allow-Origin", ["*"])
upsertKeyValue(responseHeaders, "Access-Control-Allow-Headers", ["*"])
callback({ responseHeaders })
})
@@ -177,7 +165,6 @@ export function createLoadingWindow() {
})
allowRendererPermissions(win)
wireWindowRecovery(win, "loading")
loadWindow(win, "loading.html")
@@ -187,40 +174,19 @@ export function createLoadingWindow() {
export function registerRendererProtocol() {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
protocol.handle(rendererProtocol, (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
writeLog("protocol", "rejected host", { url: request.url }, "warn")
return new Response("Not found", { status: 404 })
}
const file = resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = relative(rendererRoot, file)
if (rel.startsWith("..") || isAbsolute(rel)) {
writeLog("protocol", "rejected path", { url: request.url, file }, "warn")
return new Response("Not found", { status: 404 })
}
try {
const response = await net.fetch(pathToFileURL(file).toString())
if (response.status >= 400) {
writeLog(
"protocol",
"fetch failed",
{
url: request.url,
file,
status: response.status,
statusText: response.statusText,
},
"error",
)
}
return addDocumentPolicy(response, file)
} catch (error) {
writeLog("protocol", "fetch error", { url: request.url, file, error }, "error")
return new Response("Not found", { status: 404 })
}
return net.fetch(pathToFileURL(file).toString())
})
}
@@ -235,127 +201,6 @@ function loadWindow(win: BrowserWindow, html: string) {
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
function wireWindowRecovery(win: BrowserWindow, name: string) {
let showing = false
const sampler = createUnresponsiveSampler(win, name)
const handle = async (button: string | undefined, wait: boolean) => {
if (button === "Export Logs") {
const sampling = sampler.stopAndFlush()
await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error"))
if (wait && sampling) sampler.start()
return true
}
if (button === "Relaunch") {
sampler.stopAndFlush()
relaunchHandler()
return false
}
if (button === "Quit") {
sampler.stopAndFlush()
app.quit()
}
return false
}
const show = async (message: string, detail: string, wait: boolean) => {
if (showing || win.isDestroyed()) return
showing = true
try {
while (!win.isDestroyed()) {
const buttons = wait ? ["Relaunch", "Export Logs", "Keep Waiting"] : ["Relaunch", "Export Logs", "Quit"]
const result = await dialog.showMessageBox(win, {
type: "warning",
buttons,
defaultId: 0,
cancelId: 2,
message,
detail,
})
if (await handle(buttons[result.response], wait)) continue
return
}
} finally {
showing = false
}
}
const failed = (
event: string,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean,
) => {
writeLog(
"window",
"renderer load failed",
{
window: name,
event,
errorCode,
errorDescription,
validatedURL,
currentURL: win.webContents.getURL(),
isMainFrame,
},
"error",
)
if (!isMainFrame || errorCode === -3) return
void show(
"OpenCode failed to load",
[`Window: ${name}`, `URL: ${validatedURL}`, `Error: ${errorCode} ${errorDescription}`].join("\n"),
false,
)
}
win.webContents.on("did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
failed("did-fail-load", errorCode, errorDescription, validatedURL, isMainFrame)
})
win.webContents.on("did-fail-provisional-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
failed("did-fail-provisional-load", errorCode, errorDescription, validatedURL, isMainFrame)
})
win.webContents.on("render-process-gone", (_event, details) => {
sampler.stopAndFlush()
writeLog(
"window",
"renderer process gone",
{ window: name, currentURL: win.webContents.getURL(), details },
"error",
)
void show(
"OpenCode window terminated unexpectedly",
[`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? "<unknown>"}`].join("\n"),
false,
)
})
win.on("unresponsive", () => {
writeLog("window", "renderer unresponsive", { window: name, currentURL: win.webContents.getURL() }, "error")
sampler.start()
void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true)
})
win.on("responsive", () => {
writeLog("window", "renderer responsive", { window: name, currentURL: win.webContents.getURL() }, "error")
sampler.stopAndFlush()
})
win.webContents.on("console-message", (_event, level, message, line, sourceId) => {
if (message.toLowerCase().includes("terminal") || sourceId.toLowerCase().includes("terminal")) {
writeLog("pty", "console", { window: name, level, message, line, sourceId })
}
})
win.webContents.on("preload-error", (_event, preloadPath, error) => {
writeLog("preload", "preload error", { window: name, preloadPath, error }, "error")
})
}
function addDocumentPolicy(response: Response, file: string) {
if (!file.toLowerCase().endsWith(".html")) return response
const headers = new Headers(response.headers)
headers.set(documentPolicyHeader, jsCallStacksDocumentPolicy)
return new Response(response.body, { status: response.status, statusText: response.statusText, headers })
}
function allowRendererPermissions(win: BrowserWindow) {
win.webContents.session.setPermissionRequestHandler((webContents, permission, callback, details) => {
callback(
@@ -372,19 +217,8 @@ function allowRendererPermissions(win: BrowserWindow) {
}
function isTrustedRendererUrl(value?: string) {
return isRendererUrl(value)
}
function addRendererHeaders(value: string, headers: Record<string, any>) {
upsertKeyValue(headers, "Access-Control-Allow-Origin", ["*"])
upsertKeyValue(headers, "Access-Control-Allow-Headers", ["*"])
if (isRendererUrl(value, true)) upsertKeyValue(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy])
}
function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
-2
View File
@@ -67,8 +67,6 @@ const api: ElectronAPI = {
checkUpdate: () => ipcRenderer.invoke("check-update"),
installUpdate: () => ipcRenderer.invoke("install-update"),
setBackgroundColor: (color: string) => ipcRenderer.invoke("set-background-color", color),
exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"),
recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error),
}
contextBridge.exposeInMainWorld("api", api)
-10
View File
@@ -20,14 +20,6 @@ export type WindowConfig = {
updaterEnabled: boolean
}
export type FatalRendererError = {
error: string
url: string
version?: string
platform: string
os?: string
}
export type ElectronAPI = {
killSidecar: () => Promise<void>
installCli: () => Promise<string>
@@ -86,6 +78,4 @@ export type ElectronAPI = {
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
installUpdate: () => Promise<void>
setBackgroundColor: (color: string) => Promise<void>
exportDebugLogs: () => Promise<string>
recordFatalRendererError: (error: FatalRendererError) => Promise<void>
}
-4
View File
@@ -216,10 +216,6 @@ const createPlatform = (): Platform => {
await window.api.installUpdate()
},
exportDebugLogs: () => window.api.exportDebugLogs(),
recordFatalRendererError: (error) => window.api.recordFatalRendererError(error),
restart: async () => {
await window.api.killSidecar().catch(() => undefined)
window.api.relaunch()
+37 -43
View File
@@ -10,7 +10,7 @@
## Conventions
Per-type constructors live on the type, not as top-level re-exports. Use `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
Per-type constructors live on the type's namespace, not as top-level re-exports. Use `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for the request-shaped call API: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.model`, `LLM.updateRequest`, `LLM.generateObject`. Two ways to construct the same thing is one too many.
## Tests
@@ -21,22 +21,13 @@ Per-type constructors live on the type, not as top-level re-exports. Use `Messag
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
Primary in-repo integration point:
- `packages/opencode/src/session/llm.ts` is the session-owned orchestration layer that decides whether a request uses AI SDK or this package's native route runtime.
- `packages/opencode/src/session/llm/native-request.ts` is the lowering adapter from opencode's session/AI SDK-shaped data into this package's `LLMRequest` model.
- `packages/opencode/src/session/llm/native-runtime.ts` is the execution adapter that calls `LLMClient.stream(...)` and bridges opencode tools into this package's tool runtime.
- `packages/opencode/src/session/llm/ai-sdk.ts` keeps the default AI SDK path compatible by converting AI SDK stream parts into this package's shared `LLMEvent`s.
Keep this package independent of session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in `packages/opencode/src/session/llm.ts` and its local adapters.
### Request Flow
The intended callsite is:
```ts
const request = LLM.request({
model: OpenAI.configure({ apiKey }).responses("gpt-4o-mini"),
model: OpenAI.model("gpt-4o-mini", { apiKey }),
system: "You are concise.",
prompt: "Say hello.",
})
@@ -44,7 +35,7 @@ const request = LLM.request({
const response = yield * LLMClient.generate(request)
```
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` selects a registered route by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`. Use `LLMClient.prepare<Body>(request)` to compile a request through the route pipeline without sending it — the optional `Body` type argument narrows `.body` to the route's native shape (e.g. `prepare<OpenAIChatBody>(...)` returns a `PreparedRequestOf<OpenAIChatBody>`). The runtime body is identical; the generic is a type-level assertion.
@@ -55,8 +46,8 @@ Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g.
A route is the registered, runnable composition of four orthogonal pieces:
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
- **`Endpoint`** (`src/route/endpoint.ts`) — path construction. The host always lives on `model.baseURL`; the endpoint just supplies the path. `Endpoint.path("/chat/completions")` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Routes read `model.apiKey` at request time via `Auth.bearer` (the default; sets `Authorization: Bearer <apiKey>`) or `Auth.apiKeyHeader(name)` for providers that use a custom header (Anthropic `x-api-key`, Gemini `x-goog-api-key`). Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
Compose them via `Route.make(...)`:
@@ -66,52 +57,55 @@ export const route = Route.make({
id: "openai-chat",
provider: "openai",
protocol: OpenAIChat.protocol,
endpoint: Endpoint.path("/chat/completions", {
baseURL: "https://api.openai.com/v1",
transport: HttpTransport.httpJson({
endpoint: Endpoint.path("/chat/completions"),
auth: Auth.bearer(),
framing: Framing.sse,
encodeBody,
}),
auth: Auth.bearer(),
framing: Framing.sse,
defaults: {
baseURL: "https://api.openai.com/v1",
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
},
})
```
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `Model` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `LLMError`s.
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport``WebSocketTransport.jsonTransport.with(...)` constructs an IO template whose `prepare` receives the route endpoint/auth at compile time, builds a WebSocket URL and message, and whose `frames` yields decoded text from the socket. Same protocol and endpoint source, different transport.
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport``WebSocketTransport.json(...)` constructs a transport whose `prepare` builds a WebSocket URL and message and whose `frames` yields decoded text from the socket. Same protocol, different transport.
### URL Construction
`Endpoint` owns `{ baseURL, path, query }`. Each protocol route includes a canonical endpoint when the provider has one (e.g. `https://api.openai.com/v1`); provider helpers override endpoint fields by configuring the route before selecting a model. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) require configuration before execution.
`model.baseURL` is required; `Endpoint` only carries the path. Each protocol's `Route.make` includes a canonical URL in `defaults.baseURL` (e.g. `https://api.openai.com/v1`); provider helpers can override by passing `baseURL` in their input. Routes that have no canonical URL (OpenAI-compatible Chat, GitHub Copilot) set `baseURL: string` (required) on their input type so TypeScript catches a missing host at the call site.
For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper configures the route endpoint before calling `.model(...)`. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`).
For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper computes `baseURL` at model construction time. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`).
### Provider Facades
### Provider Definitions
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id:
Provider-facing APIs are defined with `Provider.make(...)` from `src/provider.ts`:
```ts
const openai = OpenAI.configure({ apiKey, baseURL })
const model = openai.responses("gpt-4o-mini")
export const provider = Provider.make({
id: ProviderID.make("openai"),
model: responses,
apis: { responses, chat },
})
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
const deployment = azure.responses("my-deployment")
const gateway = CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey })
const proxied = gateway.model("openai/gpt-4o-mini")
export const model = provider.model
export const apis = provider.apis
```
Keep provider facades small and explicit:
Keep provider definitions small and explicit:
- Use only `id`, `model`, and optional `apis` in `Provider.make(...)`.
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses`, `responsesWebSocket`, and `chat`.
- Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path.
- Use `model` for the default API path and `apis` for named provider-native alternatives such as OpenAI `responses` versus `chat`.
- Do not add author-facing `kind`, `version`, or `routes` fields.
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
- Resolve `apiKey``Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
Built-in providers are namespace modules from `src/providers/index.ts`, so aliases like `OpenAI.model(...)`, `OpenAI.responses(...)`, and `OpenAI.apis.chat(...)` are fine. External provider packages should default-export the `Provider.make(...)` result and may add named aliases if useful.
### Folder layout
@@ -119,7 +113,7 @@ Keep provider facades small and explicit:
packages/llm/src/
schema/ canonical Schema model, split by concern
ids.ts branded IDs, literal types, ProviderMetadata
options.ts Generation/Provider/Http options, Limits, Model, cache policy
options.ts Generation/Provider/Http options, Capabilities, Limits, ModelRef
messages.ts content parts, Message, ToolDefinition, LLMRequest
events.ts Usage, individual events, LLMEvent, PreparedRequest, LLMResponse
errors.ts error reasons, LLMError, ToolFailure
@@ -151,12 +145,12 @@ packages/llm/src/
providers/
openai-compatible.ts generic compatible helper + family model helpers
openai-compatible-profile.ts family defaults (deepseek, togetherai, ...)
azure.ts / amazon-bedrock.ts / cloudflare.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
azure.ts / amazon-bedrock.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts
tool.ts typed tool() helper
tool-runtime.ts implementation helpers for LLMClient tool execution
```
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata.
The dependency arrow points down: `providers/*.ts` files import `protocols`, `endpoint`, `auth`, and `framing`; protocols do not import provider metadata. Lower-level modules know nothing about specific providers.
### Shared protocol helpers
@@ -251,14 +245,14 @@ Use this order for every protocol module:
5. Request body construction (`fromRequest`)
6. Stream parsing (`step` and per-event handlers)
7. Protocol and route
8. Protocol route export
8. Model helper
### Rules
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `request-finish` (or `provider-error`) when a `terminal` event arrives. If a provider splits reason and usage across events, merge them in parser state before flushing.
- Emit exactly one terminal `request-finish` event for a completed response. Use `stream.terminal` to signal the run is over and have `step` emit the final event.
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
- Prefer dispatched per-event handlers (`onMessageStart`, `onContentBlockDelta`, ...) called from a small top-level `step` switch over a long if-chain. The dispatcher keeps the event surface visible at a glance.

Some files were not shown because too many files have changed in this diff Show More