Compare commits

..
46 changed files with 529 additions and 1181 deletions

No files matched your search

-1
View File
@@ -652,7 +652,6 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
OPENCODE_DESKTOP_DIST: /tmp/desktop
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -9
View File
@@ -100,17 +100,9 @@ else
fi
fi
# Git Bash and MSYS2 run as emulated x64 on Windows ARM64, so `uname -m` reports x86_64.
# The host CPU still shows in the `uname -s` suffix and in PROCESSOR_IDENTIFIER.
if [ "$os" = "windows" ] && [ "$arch" = "x64" ]; then
if [[ "$raw_os" == *-ARM64 || "${PROCESSOR_IDENTIFIER:-}" == ARM* ]]; then
arch="arm64"
fi
fi
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64|windows-arm64)
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
+1 -3
View File
@@ -417,9 +417,7 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
frequency_penalty: request.generation?.frequencyPenalty,
presence_penalty: request.generation?.presencePenalty,
stop: request.generation?.stop,
prompt_cache_key: ProviderShared.sendsPromptCacheKey(request)
? (options.promptCacheKey ?? request.promptCacheKey)
: undefined,
prompt_cache_key: request.cache === "none" ? undefined : (options.promptCacheKey ?? request.promptCacheKey),
safe_prompt: options.safePrompt,
document_image_limit: options.documentImageLimit,
document_page_limit: options.documentPageLimit,
+1 -6
View File
@@ -39,15 +39,10 @@ export const knownString = <Known extends string>() =>
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// Body-level `prompt_cache_key` is off when caching is disabled or the model
// explicitly rejects the field. Header-based affinity is unaffected.
export const sendsPromptCacheKey = (request: LLMRequest) =>
request.cache !== "none" && request.model.compatibility?.supportsPromptCacheKey !== false
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const promptCacheKey = (request: LLMRequest): string | undefined => {
if (!sendsPromptCacheKey(request) || request.promptCacheKey === undefined) return undefined
if (request.cache === "none" || request.promptCacheKey === undefined) return undefined
const chars = Array.from(request.promptCacheKey)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return request.promptCacheKey
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
+2 -3
View File
@@ -199,9 +199,8 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
// Accepts `prompt_cache_key` in the request body. Chat omits the key unless
// this is true; other protocols omit it only when this is false.
// Session-affinity headers still flow regardless.
// Accepts `prompt_cache_key` in the Chat Completions body. Chat omits the
// key unless this is set; session-affinity headers still flow regardless.
supportsPromptCacheKey: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
+1 -11
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { HttpClientRequest } from "effect/http"
import { LanguageModel, LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
import { LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
import { Mistral } from "../../src/providers/index.js"
import { MistralChat } from "../../src/protocols/index.js"
import { LLMClient } from "../../src/route.js"
@@ -188,16 +188,6 @@ describe("Mistral Chat", () => {
)
expect(uncached.body.prompt_cache_key).toBeUndefined()
const unsupported = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }),
prompt: "Hello",
promptCacheKey: "common-key",
providerOptions: { promptCacheKey: "native-key" },
}),
)
expect(unsupported.body.prompt_cache_key).toBeUndefined()
const longKey = "cache-key-".repeat(10)
const unbounded = yield* compileRequest(
LLM.request({
@@ -2117,24 +2117,6 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("omits the prompt cache key when compatibility disables it", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }),
prompt: "Hello",
promptCacheKey: "request_cache",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
expect(model.compatibility?.supportsPromptCacheKey).toBeUndefined()
expect(
(yield* compileRequest(LLM.request({ model, prompt: "Hello", promptCacheKey: "request_cache" }))).body,
).toHaveProperty("prompt_cache_key", "request_cache")
}),
)
it.effect("parses text and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -224,28 +224,147 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
await expect(view.input).toHaveText("")
})
test("a steer shows in the timeline as the composer clears, before its selection commits", async ({ page }) => {
test("waits for model selection before clearing the composer, then shows the steer in the frame the composer clears", async ({
page,
}) => {
const mock = createQueueMock([])
const view = await openQueue(page, mock, "steer")
const text = "steer after model selection commits"
const commit = await holdRoute(page, (url) => url.pathname === `/api/session/${sessionID}/model`, { method: "POST" })
const admit = await holdRoute(page, (url) => url.pathname === `/api/session/${sessionID}/prompt`, { method: "POST" })
const prompt = page.locator('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]', {
hasText: "steer while the model commits",
hasText: text,
})
await view.input.fill("steer while the model commits")
await view.input.fill(text)
await expect(view.input).toHaveText(text)
const probe = await page.evaluateHandle((expected) => {
const state = { emptyFrames: 0, emptyMutations: 0, active: true }
const hasPrompt = () => {
const editor = document.querySelector('[data-component="composer-editor"]')
const inComposer = (editor?.textContent ?? "").includes(expected)
const rows = document.querySelectorAll('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]')
const inTimeline = Array.from(rows).some((row) => (row.textContent ?? "").includes(expected))
return { inComposer, inTimeline }
}
const observer = new MutationObserver(() => {
if (!state.active) return
const current = hasPrompt()
if (!current.inComposer && !current.inTimeline) state.emptyMutations++
})
observer.observe(document.body, { childList: true, subtree: true, characterData: true })
const tick = () => {
if (!state.active) return
const current = hasPrompt()
if (!current.inComposer && !current.inTimeline) state.emptyFrames++
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
return state
}, text)
await view.input.press("Enter")
await commit.arrived
await expect(view.input).toHaveText("")
await expect(prompt).toBeVisible()
// A second Enter while model selection is in flight must not double-submit.
await view.input.press("Enter")
await expect(view.input).toHaveText(text)
await expect(prompt).toHaveCount(0)
expect(mock.prompts).toEqual([])
commit.release()
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual(["steer while the model commits"])
await admit.arrived
await expect(view.input).toHaveText("")
await expect(prompt).toBeVisible()
await expect(prompt).toHaveCount(1)
const counts = await probe.evaluate((state) => {
state.active = false
return { emptyFrames: state.emptyFrames, emptyMutations: state.emptyMutations }
})
expect(counts).toEqual({ emptyFrames: 0, emptyMutations: 0 })
admit.release()
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual([text])
expect(commit.requests).toHaveLength(1)
await expect(prompt).toHaveCount(1)
})
test("a steer into an idle session leaves the finished turn finished until delivery", async ({ page }) => {
test("keeps the draft when model selection fails and rolls back the optimistic row when prompt admission fails", async ({
page,
}) => {
const mock = createQueueMock([])
const view = await openQueue(page, mock, "steer")
const text = "recover this prompt on failure"
const prompt = page.locator('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]', {
hasText: text,
})
let failModel = true
await page.route(
(url) => url.pathname === `/api/session/${sessionID}/model`,
async (route) => {
if (route.request().method() !== "POST" || !failModel) return route.fallback()
failModel = false
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ data: { message: "Model switch failed" } }),
})
},
)
await view.input.fill(text)
await view.input.press("Enter")
await expect(page.getByText("Failed to send prompt", { exact: true })).toBeVisible()
await expect(view.input).toHaveText(text)
await expect(prompt).toHaveCount(0)
expect(mock.prompts).toEqual([])
let promptAttempts = 0
const firstAttempt = Promise.withResolvers<void>()
const releasePromptFailure = Promise.withResolvers<void>()
await page.route(
(url) => url.pathname === `/api/session/${sessionID}/prompt`,
async (route) => {
if (route.request().method() !== "POST") return route.fallback()
promptAttempts++
firstAttempt.resolve()
await releasePromptFailure.promise
await route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ data: { message: "Prompt admission failed" } }),
})
},
)
await view.input.press("Enter")
await firstAttempt.promise
await expect(view.input).toHaveText("")
await expect(prompt).toBeVisible()
releasePromptFailure.resolve()
await expect.poll(() => promptAttempts).toBe(2)
await expect(prompt).toHaveCount(0)
await expect(view.input).toHaveText(text)
})
test("a steer into an idle session leaves the finished turn settled until delivery", async ({ page }) => {
const mock = createQueueMock(
[],
[
@@ -550,6 +669,15 @@ test("Undo appends to an existing draft and restores inline attachments", async
await expect(view.input).toBeFocused()
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
const admit = await holdRoute(page, (url) => url.pathname === `/api/session/${sessionID}/prompt`, { method: "POST" })
await view.input.press("Enter")
await admit.arrived
await expect(view.input).toHaveText("")
await expect(page.locator('[data-slot="user-message-attachment-image"]')).toBeVisible()
admit.release()
await expect.poll(() => mock.prompts.length).toBe(1)
await expect(page.locator('[data-slot="user-message-attachment-image"]')).toHaveCount(1)
})
test("Undo of a comment-only prompt keeps the draft text and restores the comment once", async ({ page }) => {
+2 -3
View File
@@ -3,7 +3,6 @@ import type { SessionMessageUser } from "@opencode/client/promise"
import type { Accessor } from "solid-js"
import type { ModelSelection } from "@/providers/models/selection"
import type { ServerSDK } from "@/runtime/server/client"
import type { SessionMessageHandoff } from "@/session/handoff"
import type { ComposerStateTarget } from "./submission-state"
import type { createComposerSubmission } from "./submission-state"
@@ -52,8 +51,8 @@ export type ComposerQueue = {
export type ComposerSession = {
id: string
directory: string
handoff: {
set: (handoff: SessionMessageHandoff) => void
handoff?: {
set: (message: SessionMessageUser) => void
clear: (messageID: string) => void
}
api: {
+14 -1
View File
@@ -9,6 +9,7 @@ export function createComposerSubmission(input: {
context: (ContextItem & { key: string })[]
}) {
const initial = input.target
const before = input.target.current()
let target = input.target
let cleared: Prompt | undefined
let following: Prompt | undefined
@@ -18,6 +19,7 @@ export function createComposerSubmission(input: {
prompt: input.prompt,
context: input.context,
target: () => target,
unchanged: () => (cleared !== undefined ? target.current() === cleared : target.current() === before),
clear() {
if (initial !== target) {
initial.reset()
@@ -25,9 +27,20 @@ export function createComposerSubmission(input: {
// A preparing session may already have an unsent follow-up in its promoted composer.
if (preserveDraft && target.current().some((part) => isAttachment(part) || part.content.length > 0))
following = clonePrompt(target.current())
if (!following) target.reset()
cleared = target.current()
return
}
if (!following) target.reset()
if (target.current() !== before) {
cleared = before
return
}
target.reset()
cleared = target.current()
},
retarget(next: ComposerStateTarget, options?: { preserveDraft?: boolean }) {
+150 -45
View File
@@ -1,14 +1,8 @@
import { describe, expect, test } from "bun:test"
import type { ModelSelection } from "@/providers/models/selection"
import type { SessionMessageHandoff } from "@/session/handoff"
import type { SessionMessageUser } from "@opencode/client/promise"
import { Skill } from "@opencode/schema/skill"
import type {
ActiveComposerAdapter,
ComposerControls,
ComposerDelivery,
ComposerSession,
NewSessionComposerAdapter,
} from "./adapter"
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
import { createMemoryComposerState, type Prompt } from "./state"
import { createComposerSubmit } from "./submit"
import type { ComposerStateTarget } from "./submission-state"
@@ -81,14 +75,12 @@ function submitInput(
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,
delivery: ComposerDelivery = "steer",
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
clientCommand,
delivery: () => delivery,
editor: () => undefined,
queueScroll() {},
addToHistory: (prompt) =>
@@ -118,7 +110,7 @@ function session(input: {
return {
id: "session-1",
directory: "C:/repo",
handoff: input.handoff ?? { set() {}, clear() {} },
handoff: input.handoff,
current: input.current ?? (() => undefined),
admitted: input.admitted ?? (() => false),
api: {
@@ -237,41 +229,154 @@ describe("Composer submission", () => {
expect(state.context.items()).toEqual([])
})
test.each(["steer", "queue"] as const)(
"shows a %s in the session as the composer clears, before its selection commits",
async (delivery) => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
const committed = Promise.withResolvers<void>()
const handoffs: SessionMessageHandoff[] = []
const statuses: ("idle" | "running")[] = []
test("keeps the prompt in the composer while selection commits, then clears and hands off attachments on admission", async () => {
const state = createMemoryComposerState().capture()
const target = session({
calls: [],
statuses,
handoff: { set: (handoff) => handoffs.push(handoff), clear() {} },
switchModel: async () => committed.promise,
})
const prompt: Prompt = [
{ type: "text", content: "ship it", start: 0, end: 7 },
{
type: "image",
id: "attachment",
filename: "image.png",
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
]
await submitInput(active(state, target), undefined, "normal", undefined, undefined, undefined, delivery).submit(
new Event("submit"),
)
state.set(prompt)
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(statuses).toEqual(["running"])
// A queued prompt shows in the queue once admitted, not in the transcript.
expect(handoffs).toMatchObject(
delivery === "steer"
? [
{
message: { type: "user", text: "ship it" },
selection: { agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } },
},
]
: [],
)
committed.resolve()
},
)
const switching = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const sending = Promise.withResolvers<void>()
const calls: string[] = []
const statuses: ("idle" | "running")[] = []
const handoffs: SessionMessageUser[] = []
const history: string[] = []
const target = session({
calls,
statuses,
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
handoff: { set: (message) => handoffs.push(message), clear() {} },
switchModel: async () => {
calls.push("switch-model")
switching.resolve()
await committed.promise
},
prompt: async () => {
await sending.promise
},
})
const submission = submitInput(active(state, target), undefined, "normal", () => [], history)
const first = submission.submit(new Event("submit"))
await switching.promise
// Overlapping Enter while model selection is in flight must not send a second request.
await submission.submit(new Event("submit"))
expect(calls).toEqual(["switch-model"])
expect(state.current()).toEqual(prompt)
expect(state.context.items()).toHaveLength(1)
expect(statuses).toEqual([])
expect(handoffs).toEqual([])
expect(history).toEqual([])
committed.resolve()
await first
expect(calls).toEqual(["switch-model", "prompt"])
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect(state.context.items()).toEqual([])
expect(statuses).toEqual(["running"])
expect(history).toEqual(["add:ship itimage"])
expect(handoffs).toMatchObject([
{
type: "user",
text: "ship it",
files: [
{
data: "",
mime: "image/png",
source: { type: "uri", uri: "data:image/png;base64,YQ==" },
name: "image.png",
},
],
},
])
sending.resolve()
})
test("keeps the draft intact without clearing or flashing history when model selection fails", async () => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
const calls: string[] = []
const statuses: ("idle" | "running")[] = []
const history: string[] = []
const errors: unknown[] = []
const error = new Error("model unavailable")
const target = session({
calls,
statuses,
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
switchModel: async () => {
calls.push("switch-model")
throw error
},
})
const submission = submitInput(
active(state, target),
{ missingSelection() {}, unqueueable() {}, failed: (_kind, cause) => errors.push(cause) },
"normal",
() => [],
history,
)
await submission.submit(new Event("submit"))
expect(calls).toEqual(["switch-model"])
expect(errors).toEqual([error])
expect(statuses).toEqual([])
expect(history).toEqual([])
expect(state.current()).toEqual([{ type: "text", content: "ship it", start: 0, end: 7 }])
expect(state.context.items()).toHaveLength(1)
expect(state.retry.current()?.id).toMatch(/^msg_/)
})
test("preserves edits typed while model selection is in flight and sends the captured snapshot", async () => {
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
const switching = Promise.withResolvers<void>()
const committed = Promise.withResolvers<void>()
const sent: string[] = []
const target = session({
calls: [],
current: () => ({ agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } }),
switchModel: async () => {
switching.resolve()
await committed.promise
},
prompt: async (value) => {
sent.push(value.text)
},
})
const submission = submitInput(active(state, target))
const first = submission.submit(new Event("submit"))
await switching.promise
const edited: Prompt = [{ type: "text", content: "ship it and also run tests", start: 0, end: 26 }]
state.set(edited, 26)
committed.resolve()
await first
expect(sent).toEqual(["ship it"])
expect(state.current()).toEqual(edited)
})
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
@@ -505,7 +610,7 @@ describe("Composer submission", () => {
]
draft.set(prompt)
const handedOff = Promise.withResolvers<SessionMessageHandoff>()
const handedOff = Promise.withResolvers<SessionMessageUser>()
const admitted = Promise.withResolvers<void>()
const cleanup = Promise.withResolvers<void>()
@@ -525,7 +630,7 @@ describe("Composer submission", () => {
await submitted
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
expect((await handedOff.promise).message).toMatchObject({
expect(await handedOff.promise).toMatchObject({
type: "user",
text: "",
files: [
+107 -31
View File
@@ -1,6 +1,6 @@
import { SessionMessage } from "@opencode/schema/session-message"
import type { SessionMessageUser } from "@opencode/client/promise"
import type { Accessor } from "solid-js"
import { batch, type Accessor } from "solid-js"
import type { PromptComment } from "./comment-note"
import type { PromptHistoryComment } from "./history/entry"
import type { ImageAttachmentPart, Prompt } from "./state"
@@ -141,41 +141,93 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
if (!started) return
const session = started.session
input.addToHistory(value.prompt, value.mode)
input.resetHistory()
const restore = () => restoreSubmission(input, submission, value, comments)
if (value.mode === "normal" && !command) {
// The timeline shows a steer, and any switches it makes, as the composer clears; a queued prompt shows in
// the queue once admitted.
if (value.delivery === "steer")
session.handoff.set({
message: handoffMessage(value),
selection: {
agent: value.selection.agent,
model: {
id: value.selection.model.modelID,
providerID: value.selection.model.providerID,
variant: value.selection.variant,
},
},
})
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
await started.cleanupReady
await started.complete?.()
const finishPromptCleanup = () => {
input.addToHistory(value.prompt, value.mode)
input.resetHistory()
input.adapter.submitted()
// Like the TUI, sent context goes with the prompt rather than riding along on the next one.
submission.context.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
}
if (value.mode === "normal" && !command) {
const optimisticBusy = !input.adapter.working()
if (input.adapter.kind === "new-session") {
session.handoff?.set(handoffMessage(value))
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(
session,
value,
input.adapter.controls().model.selection.trackSessionCommit,
() => {},
).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
await started.cleanupReady
await started.complete?.()
finishPromptCleanup()
void sending.then((result) => {
if (!result.ok)
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
})
})
return
}
// Wait for selection to commit before clearing the composer, then clear in the same batch that admits the
// local row so the prompt never disappears between the two surfaces.
const admitted = Promise.withResolvers<boolean>()
const sending = sendPrompt(
session,
value,
input.adapter.controls().model.selection.trackSessionCommit,
(images) => {
if (images.length > 0) session.handoff?.set({ ...handoffMessage(value), files: images })
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
finishPromptCleanup()
admitted.resolve(true)
},
).then(
() => ({ ok: true as const }),
(error) => {
admitted.resolve(false)
return { ok: false as const, error }
},
)
if (!(await admitted.promise)) {
const result = await sending
if (!result.ok) {
if (submission.current(input.adapter.state) && submission.unchanged()) {
submission.target().retry.set({
id: value.id,
agent: value.selection.agent,
providerID: value.selection.model.providerID,
modelID: value.selection.model.modelID,
variant: value.selection.variant,
})
}
input.notify.failed("prompt", result.error)
}
return
}
void sending.then((result) => {
if (!result.ok)
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
@@ -186,6 +238,8 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
return
}
input.addToHistory(value.prompt, value.mode)
input.resetHistory()
await started.cleanupReady
await started.complete?.()
input.adapter.submitted()
@@ -337,7 +391,10 @@ function currentSelection(input: ComposerSubmitInput): ComposerSelection | undef
}
function clearSubmission(input: ComposerSubmitInput, submission: ReturnType<typeof createComposerSubmission>) {
const unchanged = submission.unchanged()
submission.clear()
if (!unchanged) return
submission.target().mode.set("normal")
input.setMode("normal")
input.closePopover()
@@ -494,6 +551,7 @@ async function sendPrompt(
session: ComposerSession,
value: ComposerSubmission,
track: ModelSelection["trackSessionCommit"] | undefined,
onAdmit: (images: NonNullable<SessionMessageUser["files"]>) => void,
) {
const request = await buildSubmissionRequest(session, value)
@@ -533,7 +591,25 @@ async function sendPrompt(
},
}
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
const images = request.files.flatMap((file) =>
file.uri.startsWith("data:")
? [{ data: "", mime: file.mime, source: { type: "uri" as const, uri: file.uri }, name: file.name }]
: [],
)
const sending = batch(() => {
const request = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
onAdmit(images)
return request
})
try {
await sending
} catch (error) {
session.handoff?.clear(value.id)
throw error
}
}
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
@@ -565,7 +641,7 @@ function failSubmission(
) {
if (messageID && session.admitted(messageID)) return
if (messageID) session.handoff.clear(messageID)
if (messageID) session.handoff?.clear(messageID)
rollback?.()
restore()
input.notify.failed(kind, error)
+19 -26
View File
@@ -1,54 +1,47 @@
import type { ModelRef, SessionMessageUser } from "@opencode/client/promise"
import type { SessionMessageUser } from "@opencode/client/promise"
import { createStore } from "solid-js/store"
import type { ServerSDK } from "@/runtime/server/client"
// A submitted prompt the timeline shows before the server admits it, with the agent and model it was sent with.
export type SessionMessageHandoff = {
message: SessionMessageUser
selection: { agent: string; model: ModelRef }
}
const MAX = 40
const [handoffs, setHandoffs] = createStore<Record<string, SessionMessageHandoff | undefined>>({})
const [messages, setMessages] = createStore<Record<string, SessionMessageUser | undefined>>({})
const handoffOrder = new Map<string, true>()
const messageOrder = new Map<string, true>()
export const setSessionMessageHandoff = (key: string, handoff: SessionMessageHandoff) => {
handoffOrder.delete(key)
handoffOrder.set(key, true)
setHandoffs(key, handoff)
export const setSessionMessageHandoff = (key: string, message: SessionMessageUser) => {
messageOrder.delete(key)
messageOrder.set(key, true)
setMessages(key, message)
while (handoffOrder.size > MAX) {
const first = handoffOrder.keys().next().value
while (messageOrder.size > MAX) {
const first = messageOrder.keys().next().value
if (first === undefined) return
handoffOrder.delete(first)
setHandoffs(first, undefined)
messageOrder.delete(first)
setMessages(first, undefined)
}
}
export const getSessionMessageHandoff = (key: string) => handoffs[key]
export const getSessionMessageHandoff = (key: string) => messages[key]
export const clearSessionMessageHandoff = (key: string, messageID: string) => {
if (handoffs[key]?.message.id !== messageID) return
handoffOrder.delete(key)
setHandoffs(key, undefined)
if (messages[key]?.id !== messageID) return
messageOrder.delete(key)
setMessages(key, undefined)
}
// Holds a session's handoff until the server's admission echo, which follows the echoes of the switches it projects.
export function createSessionMessageHandoff(key: string, sessionID: string, event: ServerSDK["event"]) {
let unsubscribe: VoidFunction | undefined
return {
set(handoff: SessionMessageHandoff) {
set(message: SessionMessageUser) {
unsubscribe?.()
setSessionMessageHandoff(key, handoff)
setSessionMessageHandoff(key, message)
unsubscribe = event.on("session.inbox.enqueued", (item) => {
if (item.data.sessionID !== sessionID || item.data.inboxID !== handoff.message.id) return
if (item.data.sessionID !== sessionID || item.data.inboxID !== message.id) return
unsubscribe?.()
unsubscribe = undefined
clearSessionMessageHandoff(key, handoff.message.id)
clearSessionMessageHandoff(key, message.id)
})
},
clear(messageID: string) {
@@ -197,7 +197,7 @@ describe("visibleTimelineMessages", () => {
})
describe("applyTimelineMessageHandoff", () => {
const message = {
const handoff = {
id: "msg_image",
type: "user",
text: "",
@@ -212,81 +212,21 @@ describe("applyTimelineMessageHandoff", () => {
time: { created: 1 },
} satisfies SessionMessageInfo
const selection = { agent: "plan", model: { id: "next", providerID: "provider", variant: "high" } }
const handoff = { message, selection }
const switched = { agent: "plan", model: selection.model }
test("shows a promoted image-only prompt before client admission", () => {
expect(applyTimelineMessageHandoff([], handoff, switched)).toEqual([message])
expect(applyTimelineMessageHandoff([], handoff)).toEqual([handoff])
})
test("adds attachments to the client's optimistic row", () => {
const optimistic = { id: message.id, type: "user", text: "", time: { created: 2 } } satisfies SessionMessageInfo
expect(applyTimelineMessageHandoff([optimistic], handoff, switched)).toEqual([
{ ...optimistic, files: message.files },
])
const optimistic = { id: handoff.id, type: "user", text: "", time: { created: 2 } } satisfies SessionMessageInfo
expect(applyTimelineMessageHandoff([optimistic], handoff)).toEqual([{ ...optimistic, files: handoff.files }])
})
test("keeps the durable attachment payload", () => {
const durable = {
...message,
...handoff,
files: [{ data: "YQ==", mime: "image/png", source: { type: "inline" } }],
} satisfies SessionMessageInfo
const source = [durable]
expect(applyTimelineMessageHandoff(source, handoff, switched)).toBe(source)
})
const agent = {
id: "msg_image_agent",
type: "agent-switched",
agent: "plan",
previous: "build",
time: { created: 1 },
} satisfies SessionMessageInfo
const model = {
id: "msg_image_model",
type: "model-switched",
model: selection.model,
time: { created: 1 },
} satisfies SessionMessageInfo
// The session takes each switch from its echo, which also lands the durable notice.
test.each([
{
name: "both switches pending",
session: { agent: "build", model: { id: "old", providerID: "provider" } },
notices: [agent, model],
},
{
name: "only a variant change",
session: { agent: "plan", model: { ...selection.model, variant: undefined } },
notices: [model],
},
{
name: "the agent echoed",
session: { agent: "plan", model: { id: "old", providerID: "provider" } },
notices: [model],
},
{ name: "both echoed", session: switched, notices: [] },
])("projects the prompt's switches ahead of it: $name", (row) => {
expect(applyTimelineMessageHandoff(messages.slice(0, 2), handoff, row.session)).toEqual([
...messages.slice(0, 2),
...row.notices,
message,
])
})
test("projects pending switches ahead of the client's optimistic row", () => {
const optimistic = { ...message, files: [] } satisfies SessionMessageInfo
const session = { agent: "build", model: selection.model }
expect(applyTimelineMessageHandoff([messages[0], optimistic], handoff, session)).toEqual([
messages[0],
agent,
message,
])
expect(applyTimelineMessageHandoff([durable], handoff)).toEqual([durable])
})
})
@@ -1,57 +1,15 @@
import type { ModelRef, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
import type { SessionMessageHandoff } from "@/session/handoff"
import type { SessionInboxInfo, SessionMessageInfo, SessionMessageUser } from "@opencode/client/promise"
// Projects a submitted prompt, and the agent and model switches it makes, until the server's echoes replace them.
// The session's live selection takes each switch's echo, so a switch projects only while the session still differs.
export function applyTimelineMessageHandoff(
messages: SessionMessageInfo[],
handoff: SessionMessageHandoff | undefined,
session: { agent?: string; model?: ModelRef } | undefined,
) {
export function applyTimelineMessageHandoff(messages: SessionMessageInfo[], handoff?: SessionMessageUser) {
if (!handoff) return messages
const notices = handoffNotices(handoff, session)
const index = messages.findIndex((message) => message.id === handoff.message.id)
const index = messages.findIndex((message) => message.id === handoff.id)
if (index < 0) return [...messages, ...notices, handoff.message]
if (index < 0) return [...messages, handoff]
const message = messages[index]
const filled = message.type === "user" && !message.files?.length
if (!filled && notices.length === 0) return messages
if (message.type !== "user" || message.files?.length) return messages
return [
...messages.slice(0, index),
...notices,
filled ? { ...message, files: handoff.message.files } : message,
...messages.slice(index + 1),
]
}
function handoffNotices(
handoff: SessionMessageHandoff,
session: { agent?: string; model?: ModelRef } | undefined,
): SessionMessageInfo[] {
const time = { created: handoff.message.time.created }
const selection = handoff.selection
return [
...(session?.agent && session.agent !== selection.agent
? [
{
id: `${handoff.message.id}_agent`,
type: "agent-switched" as const,
agent: selection.agent,
previous: session.agent,
time,
},
]
: []),
...(session?.model &&
(session.model.providerID !== selection.model.providerID ||
session.model.id !== selection.model.id ||
(session.model.variant ?? "default") !== (selection.model.variant ?? "default"))
? [{ id: `${handoff.message.id}_model`, type: "model-switched" as const, model: selection.model, time }]
: []),
]
return messages.map((item, current) => (current === index ? { ...message, files: handoff.files } : item))
}
export function visibleTimelineMessages(
@@ -68,25 +68,23 @@ export function createTimelineController(input: { session: TimelineSessionSource
const language = useLanguage()
const platform = usePlatform()
const visibleMessages = createMemo(() => {
const handedOffMessages = createMemo(() =>
applyTimelineMessageHandoff(
input.session.history.messages(),
getSessionMessageHandoff(input.session.identity.sessionKey()),
),
)
const projectedMessages = createMemo(() => {
const id = input.session.identity.sessionID()
return visibleTimelineMessages(
input.session.history.messages(),
handedOffMessages(),
id ? data.session.pending.list(id) : [],
input.session.data.info()?.revert?.messageID,
)
})
// A handoff projects after visibility: its prompt commits any staged revert, so the boundary does not hide it.
const projectedMessages = createMemo(() =>
applyTimelineMessageHandoff(
visibleMessages(),
getSessionMessageHandoff(input.session.identity.sessionKey()),
input.session.data.info(),
),
)
// Inputs that wait behind the active turn. A starting steer opens the next turn instead, so the turn before it
// stays finished, as it will once the steer is delivered.
const pendingInputIDs = createMemo(() => {
+3 -16
View File
@@ -15,14 +15,7 @@ if (!command) throw new Error("OpenCode package does not declare a binary")
const sourceCommand = packageJson.opencodeSourceBinary ?? command
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
const nodeArch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
// x64 Node runs emulated on Windows ARM64, but PROCESSOR_IDENTIFIER still names the host CPU.
const arch =
platform === "windows" && nodeArch === "x64" && process.env.PROCESSOR_IDENTIFIER?.startsWith("ARM")
? "arm64"
: nodeArch
// Windows cannot spawn npm's .cmd shim directly, so reuse the npm CLI that runs this script.
const npm = process.env.npm_execpath?.endsWith("npm-cli.js") ? [process.execPath, process.env.npm_execpath] : ["npm"]
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
const sourceBinary = platform === "windows" ? `${sourceCommand}.exe` : sourceCommand
const targetBinary = path.resolve(directory, packageJson.bin[command])
const dependencies = packageJson.optionalDependencies ?? {}
@@ -102,10 +95,7 @@ function packageNames() {
? [`${base}-baseline`, base]
: [base, `${base}-baseline`]
: [base]
// Windows 11 on ARM64 can still run the x64 binary under emulation when the native one fails.
const x64 = base.replace(/-arm64$/, "-x64")
const emulated = platform === "windows" && arch === "arm64" ? [`${x64}-baseline`, x64] : []
return [...names, ...emulated].filter((name) => dependencies[name])
return names.filter((name) => dependencies[name])
}
function copyBinary(source) {
@@ -137,15 +127,12 @@ function installPackage(name) {
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
try {
const result = childProcess.spawnSync(
npm[0],
"npm",
[
...npm.slice(1),
"install",
"--ignore-scripts",
"--no-save",
"--loglevel=error",
// npm rejects a package built for another CPU, such as arm64 under emulated x64 Node, unless forced.
...(name.includes(`-${platform}-${nodeArch}`) ? [] : ["--force"]),
"--prefix",
temp,
`${name}@${dependencies[name]}`,
+1 -2
View File
@@ -52,7 +52,7 @@ export const relocate = Effect.fnUntraced(function* (directory: string, destinat
/**
* Whether the running binary was installed by a package manager or the curl installer, the installs
* whose upgrades replace it in place. The updater decides the same question from the install location.
* bun can hang on. The updater decides the same question from the package manifest it already reads.
*/
export function installed(home: string) {
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
@@ -60,7 +60,6 @@ export function installed(home: string) {
const executable = path.resolve(process.execPath)
return (
executable.split(path.sep).includes("node_modules") ||
/[\\/]winget[\\/]packages[\\/]/i.test(executable) ||
executable === path.resolve(home, ".opencode", "bin", "opencode.exe")
)
}
+8 -83
View File
@@ -11,7 +11,7 @@ import { RetainedImage } from "./retained-image"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { errorMessage } from "../util/error"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew", "winget"] as const
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
@@ -53,20 +53,8 @@ const installNames: Record<Method, string> = {
yarn: "Yarn",
vp: "Vite+",
brew: "Homebrew",
winget: "WinGet",
}
const wingetPackage = "Anomaly.OpenCode"
const wingetArgs = [
"--id",
wingetPackage,
"--exact",
"--source",
"winget",
"--accept-source-agreements",
"--disable-interactivity",
]
function conciseDetail(input: string) {
const lines = stripVTControlCharacters(input)
.trim()
@@ -158,8 +146,6 @@ const make = Effect.gen(function* () {
const installedFormula = ["opencode", "opencode-beta", "opencode-v2"].find((name) =>
executable.includes(`${path.sep}Cellar${path.sep}${name}${path.sep}`),
)
// WinGet extracts portable packages into a directory named after the package identifier.
const installedWinget = /[\\/]winget[\\/]packages[\\/]anomaly\.opencode_/i.test(executable)
const readPolicy = Effect.fnUntraced(function* () {
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
@@ -214,7 +200,6 @@ const make = Effect.gen(function* () {
const method = Effect.fnUntraced(function* () {
if (executable === curlBinary) return "curl"
if (installedFormula) return "brew"
if (installedWinget) return "winget"
if (!installedPackage) return
const parts = executable.toLowerCase().split(path.sep).map((part) => part.replace(/^\./, ""))
@@ -244,8 +229,7 @@ const make = Effect.gen(function* () {
})?.check.method
})
const removalCommand = (method: Method) => {
if (method === "winget") return ["winget", "uninstall", ...wingetArgs]
const removal = (method: Method) => {
if (method === "curl" || method === "brew" || !installedPackage) return undefined
const commands = {
npm: ["npm", "uninstall", "--global", installedPackage],
@@ -254,12 +238,7 @@ const make = Effect.gen(function* () {
yarn: ["yarn", "global", "remove", installedPackage],
vp: ["vp", "uninstall", "-g", installedPackage],
}
return commands[method]
}
const removal = (method: Method) => {
const command = removalCommand(method)
if (!command) return undefined
const command = commands[method]
return {
command,
run: retaining(
@@ -330,39 +309,10 @@ const make = Effect.gen(function* () {
return { package: packageName, version }
})
// WinGet moderates every submission, so only its own index knows which release it can install.
const wingetVersion = Effect.fnUntraced(function* () {
const failure = (detail: string, cause?: unknown) =>
new UpgradeError(
{
title: "Could not check for OpenCode updates",
detail,
command: ["winget", "search", ...wingetArgs].join(" "),
retry: "Fix the issue above, then run opencode upgrade again.",
},
cause === undefined ? undefined : { cause },
)
const result = yield* exec(["winget", "search", ...wingetArgs], "1 minute").pipe(
Effect.mapError((cause) => failure(errorDetail(cause), cause)),
)
// Column headers are localized, but the result row always lists the identifier followed by the version.
const version = result.stdout
.split(/\r?\n|\r/)
.map((line) => line.trim().split(/\s+/))
.flatMap((tokens) => {
const index = tokens.findIndex((token) => token.toLowerCase() === wingetPackage.toLowerCase())
return index === -1 ? [] : tokens.slice(index + 1, index + 2)
})
.find((token) => parseReleaseVersion(token))
if (result.code !== 0 || !version) return yield* Effect.fail(failure(resultDetail(result)))
return version
})
const latest = (selected?: Method) =>
(selected ? Effect.succeed(selected) : method()).pipe(
Effect.flatMap((method) =>
method === "winget" ? wingetVersion() : release(method).pipe(Effect.map((data) => data.version)),
),
Effect.flatMap(release),
Effect.map((data) => data.version),
)
const temporaryDirectory = (prefix: string) =>
@@ -376,12 +326,7 @@ const make = Effect.gen(function* () {
const retaining = <A, E, R>(method: Method, effect: Effect.Effect<A, E, R>, directory = global.cache) => {
if (process.platform !== "win32" || method === "brew") return effect
// Only the installed binary is at stake; source checkouts run inside bun or node.
const owned =
method === "curl"
? executable === curlBinary
: method === "winget"
? installedWinget
: installedPackage !== undefined
const owned = method === "curl" ? executable === curlBinary : installedPackage !== undefined
if (!owned) return effect
return Effect.scoped(RetainedImage.retain(directory, "upgrade").pipe(Effect.andThen(effect))).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
@@ -426,16 +371,12 @@ const make = Effect.gen(function* () {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
const packageName =
method === "winget"
? wingetPackage
: method === "brew" && installedFormula === "opencode"
? "opencode"
: (yield* release(method)).package
method === "brew" && installedFormula === "opencode" ? "opencode" : (yield* release(method)).package
const target = `${packageName}@${version}`
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
}
const commands: Record<Exclude<Method, "bun" | "curl" | "brew" | "winget">, string[]> = {
const commands: Record<Exclude<Method, "bun" | "curl" | "brew">, string[]> = {
// Keep the old package: uninstalling it can unlink the replacement command.
npm: [
"npm",
@@ -495,22 +436,6 @@ const make = Effect.gen(function* () {
)
}
if (method === "brew") return yield* runUpgrade({ method, command: ["brew", "upgrade", packageName] })
if (method === "winget")
return yield* retaining(
method,
runUpgrade({
method,
command: [
"winget",
"upgrade",
...wingetArgs,
"--version",
version,
"--silent",
"--accept-package-agreements",
],
}),
)
return yield* retaining(method, runUpgrade({ method, command: commands[method] }))
}),
).pipe(
-13
View File
@@ -68,19 +68,6 @@ windows("installed accepts package-manager and curl binaries only", () =>
const cases = [
[path.join(home, ".bun", "install", "global", "node_modules", "@opencode", "cli", "bin", "opencode.exe"), true],
[path.join(home, ".opencode", "bin", "opencode.exe"), true],
[
path.join(
home,
"AppData",
"Local",
"Microsoft",
"WinGet",
"Packages",
"Anomaly.OpenCode_Microsoft.Winget.Source_8wekyb3d8bbwe",
"opencode.exe",
),
true,
],
[path.join(home, "AppData", "Local", "Programs", "OpenCode", "resources", "opencode-cli.exe"), false],
[path.join(home, "node_modules", "bun", "bin", "bun.exe"), false],
] as const
-65
View File
@@ -340,71 +340,6 @@ it.live("Homebrew Core installs check and upgrade the Core formula", () =>
)
})
const winget = (search: Partial<AppProcess.RunResult>) =>
fixture(
(command) => (command.args[0] === "search" ? search : {}),
"@opencode/cli",
false,
"@opencode/cli",
undefined,
(root) =>
Effect.succeed(
path.join(
root,
"AppData",
"Local",
"Microsoft",
"WinGet",
"Packages",
"Anomaly.OpenCode_Microsoft.Winget.Source_8wekyb3d8bbwe",
"opencode.exe",
),
),
)
const wingetArgs = [
"--id",
"Anomaly.OpenCode",
"--exact",
"--source",
"winget",
"--accept-source-agreements",
"--disable-interactivity",
]
it.live("WinGet installs check, upgrade, and remove the version in the WinGet index", () =>
Effect.gen(function* () {
const test = yield* winget({
stdout: Buffer.from(
"Name Id Version\r\n------------------------------\r\nOpenCode Anomaly.OpenCode 2.0.28\r\n",
),
})
expect(yield* test.updater.method()).toBe("winget")
expect(yield* test.updater.latest()).toBe("2.0.28")
yield* test.updater.upgrade("winget", "v2.0.28")
const removal = test.updater.removal("winget")
if (!removal) return yield* Effect.die("Expected winget removal command")
yield* removal.run
expect(test.commands).toEqual([
["winget", "search", ...wingetArgs],
["winget", "upgrade", ...wingetArgs, "--version", "2.0.28", "--silent", "--accept-package-agreements"],
["winget", "uninstall", ...wingetArgs],
])
// WinGet moderates submissions, so the OpenCode release service may list a version WinGet cannot install yet.
expect(test.requests).toEqual([])
}),
)
it.live("WinGet update checks report a missing package instead of a version", () =>
Effect.gen(function* () {
const test = yield* winget({
exitCode: -1978335212,
stdout: Buffer.from("No package found matching input criteria.\r\n"),
})
const error = yield* test.updater.latest().pipe(Effect.flip)
expect(error.message).toBe("No package found matching input criteria.")
}),
)
// Links are named opencode-upgrade-<pid>-<random>.exe; read them from inside the installer run.
const links = (directory: string) =>
existsSync(directory) ? readdirSync(directory).filter((name) => name.startsWith("opencode-")) : []
+12 -23
View File
@@ -630,15 +630,6 @@ export type ModelCompatibility = {
supportsEffortUpdates?: boolean
}
export type ProviderCompatibility = {
reasoningField?: ModelReasoningField
requireReasoning?: boolean
maxTokensField?: ModelMaxTokensField
requireFinishReason?: boolean
requireAssistantAfterTool?: boolean
supportsPromptCacheKey?: boolean
}
export type ModelCost = {
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
@@ -896,6 +887,18 @@ export type ProviderRequest = {
body: { [x: string]: any }
}
export type ProviderInfo = {
id: ProviderID
canonical?: ProviderID
integrationID?: IntegrationID
name: string
activation: "auto" | "enabled" | "disabled"
package: string
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type PermissionRuleset = Array<PermissionRule>
export type SessionMessageUser = {
@@ -1909,19 +1912,6 @@ export type CredentialKey = {
configuration?: FormAnswer
}
export type ProviderInfo = {
id: ProviderID
canonical?: ProviderID
integrationID?: IntegrationID
name: string
activation: "auto" | "enabled" | "disabled"
package: string
compatibility?: ProviderCompatibility
settings?: ProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: any }
}
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
export type PtyCreated = {
@@ -2251,7 +2241,6 @@ export type ConfigEntry =
name?: string
env?: Array<string>
package?: string
compatibility?: ProviderCompatibility
settings?: ConfigProviderSettings
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
@@ -77,12 +77,6 @@ export default function Download() {
</code>
<CopyStatus />
</button>
<button data-component="cli-row" onClick={handleCopyClick("winget install --id Anomaly.OpenCode -e")}>
<code>
winget install --id <strong>Anomaly.OpenCode</strong> -e
</code>
<CopyStatus />
</button>
<button data-component="cli-row" onClick={handleCopyClick("paru -S opencode-beta")}>
<code>
paru -S <strong>opencode-beta</strong>
@@ -109,15 +103,6 @@ export default function Download() {
</code>
<CopyStatus />
</button>
<button
data-component="cli-row"
onClick={handleCopyClick("winget install --id Anomaly.OpenCode.Desktop -e")}
>
<code>
winget install --id <strong>Anomaly.OpenCode.Desktop</strong> -e
</code>
<CopyStatus />
</button>
<div data-component="download-row">
<div data-component="download-info">
<span data-slot="icon">
-13
View File
@@ -88,9 +88,6 @@ export default function Home() {
<Tabs.Trigger value="brew" data-slot="tab">
brew
</Tabs.Trigger>
<Tabs.Trigger value="winget" data-slot="tab">
winget
</Tabs.Trigger>
<Tabs.Trigger value="paru" data-slot="tab">
paru
</Tabs.Trigger>
@@ -138,16 +135,6 @@ export default function Home() {
<CopyStatus />
</button>
</Tabs.Content>
<Tabs.Content as="pre" data-slot="panel" value="winget">
<button data-copy data-slot="command" onClick={handleCopyClick}>
<span>
<span data-slot="protocol">winget install --id </span>
<span data-slot="highlight">Anomaly.OpenCode</span>
<span> -e</span>
</span>
<CopyStatus />
</button>
</Tabs.Content>
<Tabs.Content as="pre" data-slot="panel" value="paru">
<button data-copy data-slot="command" onClick={handleCopyClick}>
<span>
@@ -68,8 +68,6 @@ export const Plugin = define({
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compatibility !== undefined)
provider.compatibility = { ...provider.compatibility, ...item.compatibility }
if (item.settings !== undefined) provider.settings = Provider.mergeOverlay(provider.settings, item.settings)
if (item.headers !== undefined) provider.headers = Provider.mergeHeaders(provider.headers, item.headers)
if (item.body !== undefined) provider.body = Provider.mergeOverlay(provider.body, item.body)
-4
View File
@@ -201,10 +201,6 @@ const layer = Layer.effect(
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
compatibility:
provider?.compatibility === undefined
? model.compatibility
: { ...provider.compatibility, ...model.compatibility },
settings: Provider.mergeOverlay(
Provider.modelSettings(provider?.settings),
Provider.modelSettings(model.settings),
@@ -1,5 +1,6 @@
import { Effect } from "effect"
import { define } from "@opencode/plugin/effect/plugin"
import { Provider } from "../../provider.js"
// Chat-completions `prompt_cache_key` body support for catalog providers
// without a dedicated AI package. Audited per provider (docs + OpenAPI +
@@ -24,13 +25,14 @@ const providerIDs = [
export const PromptCacheKeyPlugin = define({
id: "opencode.provider.prompt-cache-key",
effect: Effect.fn(function* (ctx) {
// Set on the provider so configured provider and model compatibility can override it.
yield* ctx.provider.transform((providers) => {
yield* ctx.model.transform((models) => {
for (const id of providerIDs) {
if (!providers.get(id)) continue
providers.update(id, (provider) => {
provider.compatibility = { ...provider.compatibility, supportsPromptCacheKey: true }
})
const providerID = Provider.ID.make(id)
for (const model of models.list(providerID)) {
models.update(model.providerID, model.id, (draft) => {
draft.compatibility = { ...draft.compatibility, supportsPromptCacheKey: true }
})
}
}
})
}),
+7 -9
View File
@@ -398,19 +398,17 @@ export const layer = Layer.effect(
retry: (event: Parameters<Prepared["retry"]>[0]) =>
hooks.trigger("session", "retry", event).pipe(Effect.asVoid),
// Permission.assert and the question tool throw declines as defects so tools cannot
// catch them and turn a "no" into model-visible output. Recover them here as failures,
// including declines a tool rethrew and its wrapper attached to a Tool.Error, as Promise
// plugin tools do.
// catch them and turn a "no" into model-visible output. Recover them here as failures.
executeTool: (call: Parameters<Prepared["executeTool"]>[0]) =>
tools.execute({ ...call, definitions: hooked }).pipe(
Effect.catchCauseFilter(
(cause) => {
const decline = cause.reasons
.map((r) => (Cause.isDieReason(r) ? r.defect : Cause.isFailReason(r) ? r.error.error : undefined))
.find(
(value): value is Permission.DeclinedError | QuestionTool.CancelledError =>
value instanceof Permission.DeclinedError || value instanceof QuestionTool.CancelledError,
)
const decline = cause.reasons.flatMap((r) =>
Cause.isDieReason(r) &&
(r.defect instanceof Permission.DeclinedError || r.defect instanceof QuestionTool.CancelledError)
? [r.defect]
: [],
)[0]
return decline ? Result.succeed(decline) : Result.fail(cause)
},
(decline) => Effect.fail(decline),
+1 -4
View File
@@ -110,14 +110,11 @@ export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
}
function migrateStandardProvider(info: ConfigProviderV1.Info) {
// V1 `setCacheKey` toggled `prompt_cache_key` for the whole provider; it is not a provider package setting.
const { setCacheKey, ...rest } = info.options ?? {}
const options = ConfigProviderOptionsV1.provider(rest)
const options = ConfigProviderOptionsV1.provider(info.options ?? {})
return {
name: info.name,
env: info.env,
package: info.npm ? Provider.aisdk(info.npm) : undefined,
compatibility: setCacheKey === undefined ? undefined : { supportsPromptCacheKey: setCacheKey },
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
headers: info.options && options.headers,
body: info.options && options.body,
@@ -614,23 +614,6 @@ describe("ConfigNormalize", () => {
expect(result.encoded.providers).not.toHaveProperty(["gateway", "models", "untouched", "compatibility"])
})
test("migrates the legacy setCacheKey provider option into provider compatibility", () => {
const result = normalized({
provider: {
proxy: { npm: "@ai-sdk/openai-compatible", options: { baseURL: "https://proxy.test/v1", setCacheKey: true } },
openai: { options: { setCacheKey: false } },
untouched: { options: { baseURL: "https://untouched.test/v1" } },
},
})
expect(result.encoded.providers).toMatchObject({
proxy: { compatibility: { supportsPromptCacheKey: true }, settings: { baseURL: "https://proxy.test/v1" } },
openai: { compatibility: { supportsPromptCacheKey: false } },
})
expect(result.encoded.providers).not.toHaveProperty(["proxy", "settings", "setCacheKey"])
expect(result.encoded.providers).not.toHaveProperty(["openai", "settings", "setCacheKey"])
expect(result.encoded.providers).not.toHaveProperty(["untouched", "compatibility"])
})
test("invalid legacy provider overlays skip only that provider", () => {
const result = normalized({
provider: {
-138
View File
@@ -12,7 +12,6 @@ import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { AzurePlugin } from "@opencode/core/plugin/provider/azure"
import { OpenAIPlugin } from "@opencode/core/plugin/provider/openai"
import { PromptCacheKeyPlugin } from "@opencode/core/plugin/provider/prompt-cache-key"
import { XAIPlugin } from "@opencode/core/plugin/provider/xai"
import { Provider } from "@opencode/core/provider"
import { withEnv } from "../fixture/env"
@@ -76,143 +75,6 @@ describe("ConfigProviderPlugin.Plugin", () => {
}),
)
it.effect("keeps provider and model compatibility precedence across config documents", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const providerID = Provider.ID.make("proxy")
yield* providers.transform((editor) => {
editor.update(providerID, (provider) => {
provider.package = "@opencode/ai/providers/openai/chat"
})
editor.models.update(providerID, Model.ID.make("catalog"), () => {})
})
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
proxy: {
// Model-version features are model-only and are not accepted on the provider.
compatibility: { requireFinishReason: false, supportsEffortUpdates: false },
models: { claude: { compatibility: { supportsPromptCacheKey: true } } },
},
},
}),
}),
new Document({
type: "document",
info: decode({
providers: {
proxy: {
compatibility: { supportsPromptCacheKey: false },
models: { alias: { modelID: "catalog" } },
},
},
}),
}),
])
expect((yield* providers.get(providerID))?.compatibility).toEqual({
requireFinishReason: false,
supportsPromptCacheKey: false,
})
// A later provider default does not override an earlier model setting.
expect(required(yield* models.get(providerID, Model.ID.make("claude"))).compatibility).toEqual({
requireFinishReason: false,
supportsPromptCacheKey: true,
})
// A model first defined in a later document still inherits earlier provider values.
expect(required(yield* models.get(providerID, Model.ID.make("alias"))).compatibility).toEqual({
requireFinishReason: false,
supportsPromptCacheKey: false,
})
}),
)
it.effect("lets configured provider compatibility override built-in provider defaults", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const providers = yield* Provider.Service
const models = yield* Model.Service
const providerID = Provider.ID.make("venice")
yield* providers.transform((editor) => {
editor.update(providerID, (provider) => {
provider.package = "@opencode/ai/providers/openai-compatible"
})
editor.models.update(providerID, Model.ID.make("chat"), () => {})
editor.models.update(providerID, Model.ID.make("keyed"), () => {})
})
yield* PromptCacheKeyPlugin.effect(yield* PluginHost.make(plugin))
expect(required(yield* models.get(providerID, Model.ID.make("chat"))).compatibility).toEqual({
supportsPromptCacheKey: true,
})
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
venice: {
compatibility: { supportsPromptCacheKey: false },
models: { keyed: { compatibility: { supportsPromptCacheKey: true } } },
},
},
}),
}),
])
expect(required(yield* models.get(providerID, Model.ID.make("chat"))).compatibility).toEqual({
supportsPromptCacheKey: false,
})
expect(required(yield* models.get(providerID, Model.ID.make("keyed"))).compatibility).toEqual({
supportsPromptCacheKey: true,
})
}),
)
it.effect("applies provider compatibility to every provider model with model overrides", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const providerID = Provider.ID.make("proxy")
yield* providers.transform((editor) => {
editor.update(providerID, (provider) => {
provider.package = "@opencode/ai/providers/openai/chat"
})
editor.models.update(providerID, Model.ID.make("catalog"), (model) => {
model.compatibility = { maxTokensField: "max_tokens" }
})
})
yield* addPlugin([
new Document({
type: "document",
info: decode({
providers: {
proxy: {
compatibility: { supportsPromptCacheKey: false, requireFinishReason: false },
models: {
configured: { compatibility: { supportsPromptCacheKey: true } },
},
},
},
}),
}),
])
const catalog = required(yield* models.get(providerID, Model.ID.make("catalog")))
const configured = required(yield* models.get(providerID, Model.ID.make("configured")))
expect(catalog.compatibility).toEqual({
maxTokensField: "max_tokens",
supportsPromptCacheKey: false,
requireFinishReason: false,
})
expect(configured.compatibility).toEqual({ supportsPromptCacheKey: true, requireFinishReason: false })
// The OpenAI chat package enables the key by default; provider config overrides it on the runtime model.
expect((yield* ModelResolver.fromCatalogModel(catalog)).compatibility?.supportsPromptCacheKey).toBe(false)
expect((yield* ModelResolver.fromCatalogModel(configured)).compatibility?.supportsPromptCacheKey).toBe(true)
}),
)
it.effect("keeps the provider websocket policy out of model settings", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
+1 -84
View File
@@ -1,13 +1,12 @@
import { expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { Permission } from "@opencode/core/permission"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { Tool } from "@opencode/core/tool"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Cause, Deferred, Effect, Exit, Fiber, Option } from "effect"
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
@@ -54,85 +53,3 @@ it.live("Promise tool executors receive interruption through their AbortSignal",
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
const execute = (snapshot: Tool.Snapshot, name: string) =>
snapshot
.execute({
sessionID: Session.ID.make("ses_promise_tool_error"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool_error"),
call: { type: "tool-call", id: `call_${name}`, name, input: {} },
})
.pipe(Effect.exit)
it.live("Promise tool throws become tool failures", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
yield* PluginPromise.fromPromise({
id: "throwing-tools",
async setup(context) {
await context.tool.transform((editor) => {
editor.add({
name: "plain",
description: "Throw an Error",
input: { type: "object", properties: {}, additionalProperties: false },
options: { codemode: false },
execute: async () => {
throw new Error("plain failure")
},
})
editor.add({
name: "typed",
description: "Throw a Tool.Error",
input: { type: "object", properties: {}, additionalProperties: false },
options: { codemode: false },
execute: async () => {
throw new Tool.Error({ message: "typed failure", metadata: { code: 7 } })
},
})
})
},
}).effect(yield* PluginHost.make(plugins))
const snapshot = yield* tools.snapshot()
const plain = yield* execute(snapshot, "plain")
const typed = yield* execute(snapshot, "typed")
expect(Option.getOrUndefined(Exit.findErrorOption(plain))).toMatchObject({ message: "plain failure" })
expect(Option.getOrUndefined(Exit.findErrorOption(typed))).toMatchObject({
message: "typed failure",
metadata: { code: 7 },
})
}),
)
it.live("Promise tools that rethrow a decline attach it to the tool failure", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const declined = new Permission.DeclinedError()
yield* tools.transform((editor) =>
editor.add({
name: "guarded",
description: "Decline every call",
input: { type: "object", properties: {}, additionalProperties: false },
options: { codemode: false },
execute: () => Effect.die(declined),
}),
)
yield* PluginPromise.fromPromise({
id: "wrapping-tool",
async setup(context) {
await context.tool.transform((editor) =>
editor.update("guarded", (tool) => {
const original = tool.execute
tool.execute = (input, context) => original(input, context)
}),
)
},
}).effect(yield* PluginHost.make(plugins))
const exit = yield* execute(yield* tools.snapshot(), "guarded")
expect(Option.getOrUndefined(Exit.findErrorOption(exit))?.error).toBe(declined)
}),
)
-13
View File
@@ -4922,14 +4922,6 @@ describe("SessionRunnerLLM", () => {
output: Schema.Struct({}),
execute: () => Effect.die(new Permission.DeclinedError()),
},
wrapped: {
name: "wrapped",
description: "Fail with a decline attached to a tool error",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: () =>
Effect.fail(new Tool.Error({ message: "Wrapped decline", error: new Permission.DeclinedError() })),
},
},
{ codemode: false },
)
@@ -4939,7 +4931,6 @@ describe("SessionRunnerLLM", () => {
TestLLM.toolCalls(
LLMEvent.toolCall({ id: "call-failed", name: "failed", input: {} }),
LLMEvent.toolCall({ id: "call-declined", name: "declined", input: {} }),
LLMEvent.toolCall({ id: "call-wrapped", name: "wrapped", input: {} }),
),
)
@@ -4956,10 +4947,6 @@ describe("SessionRunnerLLM", () => {
{ id: "call-declined" },
{ error: { type: "aborted", message: "The user declined this tool call" } },
),
Expected.failedTool(
{ id: "call-wrapped" },
{ error: { type: "aborted", message: "The user declined this tool call" } },
),
]),
])
})
+5 -12
View File
@@ -622,17 +622,10 @@ function attempt<A>(evaluate: (signal: AbortSignal) => PromiseLike<A>) {
type RuntimeSchema = Schema.Codec<unknown, unknown>
const executePromiseTool = (tool: Info, input: any, context: Tool.Context) =>
Effect.tryPromise({
try: (signal) =>
tool.execute(input, {
...context,
signal,
progress: (update) => Effect.runPromise(context.progress(update), { signal }),
}),
catch: (error) => error,
}).pipe(
Effect.catch((error) => {
if (error instanceof Tool.Error) return Effect.fail(error)
return Effect.fail(new Tool.Error({ message: error instanceof Error ? error.message : String(error), error }))
Effect.promise((signal) =>
tool.execute(input, {
...context,
signal,
progress: (update) => Effect.runPromise(context.progress(update), { signal }),
}),
)
-30
View File
@@ -14403,9 +14403,6 @@
"package": {
"type": "string"
},
"compatibility": {
"$ref": "#/components/schemas/Provider.Compatibility"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
},
@@ -17721,30 +17718,6 @@
}
]
},
"Provider.Compatibility": {
"type": "object",
"properties": {
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
"requireFinishReason": {
"type": "boolean"
},
"requireAssistantAfterTool": {
"type": "boolean"
},
"supportsPromptCacheKey": {
"type": "boolean"
}
},
"additionalProperties": false
},
"Provider.ID": {
"type": "string"
},
@@ -17770,9 +17743,6 @@
"package": {
"type": "string"
},
"compatibility": {
"$ref": "#/components/schemas/Provider.Compatibility"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
},
-3
View File
@@ -3,7 +3,6 @@ export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { ProviderCompatibility } from "../model-compatibility.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
@@ -96,8 +95,6 @@ export class Info extends Schema.Class<Info>("Config.Provider")({
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
package: Schema.String.pipe(optional),
// Defaults for every model of this provider; model compatibility overrides individual fields.
compatibility: ProviderCompatibility.pipe(optional),
...Overlays,
models: Schema.Record(Schema.String, Model).pipe(optional),
}) {}
@@ -1,35 +0,0 @@
import { Schema, Struct } from "effect"
import { optional } from "./schema.js"
// Shared by model and provider schemas; kept separate because model.ts imports provider.ts.
export type ReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
Schema.String,
]).annotate({ identifier: "Model.ReasoningField" })
export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
identifier: "Model.MaxTokensField",
})
export type MaxTokensField = typeof MaxTokensField.Type
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional),
/** Require every assistant message to include its reasoning field, even when empty. */
requireReasoning: Schema.Boolean.pipe(optional),
maxTokensField: MaxTokensField.pipe(optional),
requireFinishReason: Schema.Boolean.pipe(optional),
requireAssistantAfterTool: Schema.Boolean.pipe(optional),
supportsPromptCacheKey: Schema.Boolean.pipe(optional),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
supportsThinkingBlockBinding: Schema.Boolean.pipe(optional),
/** Supports per-message effort updates. Overrides model-ID detection. */
supportsEffortUpdates: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Compatibility" })
// Provider defaults inherited by its models. Model-version features stay model-only.
export interface ProviderCompatibility extends Schema.Schema.Type<typeof ProviderCompatibility> {}
export const ProviderCompatibility = Schema.Struct(
Struct.omit(Compatibility.fields, ["supportsThinkingBlockBinding", "supportsEffortUpdates"]),
).annotate({ identifier: "Provider.Compatibility" })
+26 -3
View File
@@ -3,7 +3,6 @@ export * as Model from "./model.js"
import { Schema } from "effect"
import { optional, statics } from "./schema.js"
import { Provider } from "./provider.js"
import { Compatibility, MaxTokensField, ReasoningField } from "./model-compatibility.js"
import { Money } from "./money.js"
import { ephemeral, inventory } from "./event.js"
@@ -43,11 +42,20 @@ export const Ref = Schema.Struct({
)
export interface Ref extends Schema.Schema.Type<typeof Ref> {}
export { Compatibility, MaxTokensField, ReasoningField }
export const Family = Schema.String.pipe(Schema.brand("Model.Family"))
export type Family = typeof Family.Type
export type ReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
export const ReasoningField: Schema.Codec<ReasoningField> = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
Schema.String,
]).annotate({ identifier: "Model.ReasoningField" })
export const MaxTokensField = Schema.Literals(["max_completion_tokens", "max_tokens"]).annotate({
identifier: "Model.MaxTokensField",
})
export type MaxTokensField = typeof MaxTokensField.Type
export const Settings = Schema.StructWithRest(
Schema.Struct({
compaction: Provider.Compaction.pipe(optional),
@@ -63,6 +71,21 @@ export const Overlays = {
body: Schema.Record(Schema.String, Schema.Any).pipe(optional),
}
export interface Compatibility extends Schema.Schema.Type<typeof Compatibility> {}
export const Compatibility = Schema.Struct({
reasoningField: ReasoningField.pipe(optional),
/** Require every assistant message to include its reasoning field, even when empty. */
requireReasoning: Schema.Boolean.pipe(optional),
maxTokensField: MaxTokensField.pipe(optional),
requireFinishReason: Schema.Boolean.pipe(optional),
requireAssistantAfterTool: Schema.Boolean.pipe(optional),
supportsPromptCacheKey: Schema.Boolean.pipe(optional),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
supportsThinkingBlockBinding: Schema.Boolean.pipe(optional),
/** Supports per-message effort updates. Overrides model-ID detection. */
supportsEffortUpdates: Schema.Boolean.pipe(optional),
}).annotate({ identifier: "Model.Compatibility" })
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
export const Capabilities = Schema.Struct({
tools: Schema.Boolean,
-3
View File
@@ -2,7 +2,6 @@ export * as Provider from "./provider.js"
import { Effect, Schema } from "effect"
import { Integration } from "./integration.js"
import { ProviderCompatibility } from "./model-compatibility.js"
import { optional, statics } from "./schema.js"
import { ephemeral, inventory } from "./event.js"
@@ -79,8 +78,6 @@ export const Info = Schema.Struct({
name: Schema.String,
activation: Activation,
package: Package,
/** Inherited by every model of this provider; model compatibility overrides individual fields. */
compatibility: ProviderCompatibility.pipe(optional),
...Overlays,
})
.annotate({ identifier: "Provider.Info" })
-222
View File
@@ -1,222 +0,0 @@
#!/usr/bin/env bun
import { Script } from "@opencode/script"
import { mkdir, rm } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
if (Script.channel !== "latest" || !/^\d+\.\d+\.\d+$/.test(Script.version)) {
throw new Error("WinGet publishing requires a latest release version")
}
const root = fileURLToPath(new URL("..", import.meta.url))
const dryRun = process.argv.includes("--dry-run")
const token = process.env.WINGET_GITHUB_TOKEN
const cli = path.resolve(process.env.OPENCODE_CLI_DIST ?? path.join(root, "packages", "cli", "dist"))
const desktop = process.env.OPENCODE_DESKTOP_DIST
if (!desktop) throw new Error("OPENCODE_DESKTOP_DIST is required")
const outdir = path.join(cli, "winget")
const manifestVersion = "1.12.0"
const packages = [
{
identifier: "Anomaly.OpenCode",
name: "OpenCode CLI",
moniker: "opencode",
description: "The open source AI coding agent for the terminal.",
installer: [
"InstallerType: zip",
"NestedInstallerType: portable",
"NestedInstallerFiles:",
"- RelativeFilePath: opencode.exe",
" PortableCommandAlias: opencode",
"Commands:",
"- opencode",
],
files: [
{ architecture: "x64", file: path.join(cli, "opencode-windows-x64-baseline.zip") },
{ architecture: "arm64", file: path.join(cli, "opencode-windows-arm64.zip") },
],
},
{
identifier: "Anomaly.OpenCode.Desktop",
// Matches the display name the installer registers with Windows, without its version suffix.
name: "OpenCode",
moniker: "opencode-desktop",
description: "The open source AI coding agent as a desktop app.",
installer: [
"InstallerType: nullsoft",
"Scope: user",
"UpgradeBehavior: install",
// electron-builder derives the NSIS uninstall key from the production app ID, ai.opencode.desktop.
"ProductCode: d074f30d-5f88-5885-b075-be1348cc7676",
"Protocols:",
"- opencode",
],
files: [
{ architecture: "x64", file: path.join(desktop, "opencode-desktop-win-x64.exe") },
{ architecture: "arm64", file: path.join(desktop, "opencode-desktop-win-arm64.exe") },
],
},
]
await rm(outdir, { recursive: true, force: true })
const prepared = await Promise.all(packages.map(prepare))
prepared.forEach((item) => console.log(`Prepared ${item.identifier} ${Script.version} in ${item.directory}`))
if (dryRun) process.exit(0)
if (!token) {
console.log("skipped WinGet submission: WINGET_GITHUB_TOKEN is not set")
process.exit(0)
}
for (const item of prepared) await submit(item)
async function prepare(input: (typeof packages)[number]) {
const directory = path.join(outdir, input.identifier)
const header = (type: string) => [
`# yaml-language-server: $schema=https://aka.ms/winget-manifest.${type}.${manifestVersion}.schema.json`,
"",
`PackageIdentifier: ${input.identifier}`,
`PackageVersion: ${Script.version}`,
]
const footer = (type: string) => [`ManifestType: ${type}`, `ManifestVersion: ${manifestVersion}`, ""]
const installers = await Promise.all(
input.files.map(async (item) => {
const file = Bun.file(item.file)
if (!(await file.exists()) || !file.size) throw new Error(`Missing WinGet installer: ${item.file}`)
const sha256 = new Bun.CryptoHasher("sha256")
for await (const chunk of file.stream()) sha256.update(chunk)
return [
`- Architecture: ${item.architecture}`,
` InstallerUrl: https://opencode.ai/files/bin/${encodeURIComponent(Script.version)}/${path.basename(item.file)}`,
` InstallerSha256: ${sha256.digest("hex").toUpperCase()}`,
]
}),
)
const manifests = {
[`${input.identifier}.yaml`]: [...header("version"), "DefaultLocale: en-US", ...footer("version")],
[`${input.identifier}.installer.yaml`]: [
...header("installer"),
...input.installer,
`ReleaseDate: ${new Date().toISOString().slice(0, 10)}`,
"Installers:",
...installers.flat(),
...footer("installer"),
],
[`${input.identifier}.locale.en-US.yaml`]: [
...header("defaultLocale"),
"PackageLocale: en-US",
// Matches the publisher the desktop installer registers with Windows.
"Publisher: OpenCode",
"PublisherUrl: https://anoma.ly/",
"PublisherSupportUrl: https://github.com/anomalyco/opencode/issues",
`PackageName: ${input.name}`,
"PackageUrl: https://opencode.ai/",
"License: MIT",
"LicenseUrl: https://github.com/anomalyco/opencode/blob/HEAD/LICENSE",
`ShortDescription: ${input.description}`,
`Moniker: ${input.moniker}`,
"Tags:",
"- ai",
"- coding-agent",
"- developer-tools",
"Documentations:",
"- DocumentLabel: Docs",
" DocumentUrl: https://opencode.ai/docs/",
...footer("defaultLocale"),
],
}
const files = Object.fromEntries(Object.entries(manifests).map(([name, lines]) => [name, lines.join("\n")]))
await mkdir(directory, { recursive: true })
await Promise.all(Object.entries(files).map(([name, content]) => Bun.write(path.join(directory, name), content)))
return { identifier: input.identifier, directory, files }
}
// Opens the pull request from the token owner's fork, the only way to contribute to microsoft/winget-pkgs.
async function submit(input: Awaited<ReturnType<typeof prepare>>) {
const upstream = "microsoft/winget-pkgs"
const folder = ["manifests", "a", ...input.identifier.split("."), Script.version].join("/")
const branch = `${input.identifier}-${Script.version}`
if ((await github(`/repos/${upstream}/contents/${folder}`)).status === 200) {
console.log(`${input.identifier} ${Script.version} is already in ${upstream}`)
return
}
const fork: { owner: { login: string }; full_name: string } = await json(
await github(`/repos/${upstream}/forks`, { method: "POST", body: { default_branch_only: true } }),
)
const pulls: unknown[] = await json(
await github(`/repos/${upstream}/pulls?state=all&head=${encodeURIComponent(`${fork.owner.login}:${branch}`)}`),
)
if (pulls.length) {
console.log(`${input.identifier} ${Script.version} already has a pull request`)
return
}
// Forks share objects with their upstream, so the branch can start from upstream master without syncing the fork.
const head: { object: { sha: string } } = await json(await github(`/repos/${upstream}/git/ref/heads/master`))
const base: { tree: { sha: string } } = await json(await github(`/repos/${upstream}/git/commits/${head.object.sha}`))
const tree: { sha: string } = await json(
await github(`/repos/${fork.full_name}/git/trees`, {
method: "POST",
body: {
base_tree: base.tree.sha,
tree: Object.entries(input.files).map(([name, content]) => ({
path: `${folder}/${name}`,
mode: "100644",
type: "blob",
content,
})),
},
}),
)
const title = `New version: ${input.identifier} version ${Script.version}`
const commit: { sha: string } = await json(
await github(`/repos/${fork.full_name}/git/commits`, {
method: "POST",
body: { message: title, tree: tree.sha, parents: [head.object.sha] },
}),
)
const created = await github(`/repos/${fork.full_name}/git/refs`, {
method: "POST",
body: { ref: `refs/heads/${branch}`, sha: commit.sha },
})
// A retried release can find the branch from an attempt that failed before opening its pull request.
await json(
created.status === 422
? await github(`/repos/${fork.full_name}/git/refs/heads/${branch}`, {
method: "PATCH",
body: { sha: commit.sha, force: true },
})
: created,
)
const run = process.env.GITHUB_RUN_ID
? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`
: undefined
const pull: { html_url: string } = await json(
await github(`/repos/${upstream}/pulls`, {
method: "POST",
body: {
title,
head: `${fork.owner.login}:${branch}`,
base: "master",
body: `Published by the [OpenCode](https://github.com/anomalyco/opencode) release workflow${run ? ` in ${run}` : ""}.`,
},
}),
)
console.log(`Opened ${pull.html_url}`)
}
function github(route: string, init?: { method?: string; body?: unknown }) {
return fetch(`https://api.github.com${route}`, {
method: init?.method ?? "GET",
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
})
}
async function json(response: Response) {
if (!response.ok) throw new Error(`GitHub ${response.url} returned ${response.status}: ${await response.text()}`)
return response.json()
}
-7
View File
@@ -116,10 +116,3 @@ if (Script.release) {
console.log("\n=== desktop ===\n")
await $`bun ./packages/desktop/scripts/publish.ts`
}
if (Script.release && Script.channel === "latest") {
console.log("\n=== winget ===\n")
// Everything else is published by now, and WinGet validation can reject a submission for reasons unrelated to the release.
if ((await $`bun ./script/publish-winget.ts`.nothrow()).exitCode !== 0)
console.log("::warning::WinGet submission failed; see the winget step output above")
}
-30
View File
@@ -14403,9 +14403,6 @@
"package": {
"type": "string"
},
"compatibility": {
"$ref": "#/components/schemas/Provider.Compatibility"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
},
@@ -17721,30 +17718,6 @@
}
]
},
"Provider.Compatibility": {
"type": "object",
"properties": {
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
"requireFinishReason": {
"type": "boolean"
},
"requireAssistantAfterTool": {
"type": "boolean"
},
"supportsPromptCacheKey": {
"type": "boolean"
}
},
"additionalProperties": false
},
"Provider.ID": {
"type": "string"
},
@@ -17770,9 +17743,6 @@
"package": {
"type": "string"
},
"compatibility": {
"$ref": "#/components/schemas/Provider.Compatibility"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
},
-30
View File
@@ -14403,9 +14403,6 @@
"package": {
"type": "string"
},
"compatibility": {
"$ref": "#/components/schemas/Provider.Compatibility"
},
"settings": {
"$ref": "#/components/schemas/Config.Provider.Settings"
},
@@ -17721,30 +17718,6 @@
}
]
},
"Provider.Compatibility": {
"type": "object",
"properties": {
"reasoningField": {
"$ref": "#/components/schemas/Model.ReasoningField"
},
"requireReasoning": {
"type": "boolean"
},
"maxTokensField": {
"$ref": "#/components/schemas/Model.MaxTokensField"
},
"requireFinishReason": {
"type": "boolean"
},
"requireAssistantAfterTool": {
"type": "boolean"
},
"supportsPromptCacheKey": {
"type": "boolean"
}
},
"additionalProperties": false
},
"Provider.ID": {
"type": "string"
},
@@ -17770,9 +17743,6 @@
"package": {
"type": "string"
},
"compatibility": {
"$ref": "#/components/schemas/Provider.Compatibility"
},
"settings": {
"$ref": "#/components/schemas/Provider.Settings"
},
+2 -7
View File
@@ -13,7 +13,6 @@ OpenCode is an open source AI coding agent. It’s available as a terminal-based
tabs={[
{ label: "curl", code: "curl -fsSL https://opencode.ai/v2/install | bash" },
{ label: "homebrew", code: "brew install anomalyco/tap/opencode-v2" },
{ label: "winget", code: "winget install --id Anomaly.OpenCode -e" },
{ label: "npm", code: "npm install -g @opencode/cli" },
{ label: "bun", code: "bun install -g --trust @opencode/cli" },
{ label: "pnpm", code: "pnpm add -g --allow-build=@opencode/cli @opencode/cli" },
@@ -26,6 +25,8 @@ OpenCode is an open source AI coding agent. It’s available as a terminal-based
The npm package uses a postinstall script to select the native `opencode` binary for your platform. The Bun and pnpm commands
explicitly allow the required script; Vite+ requires no additional flag.
Windows package managers are not supported.
Download a standalone CLI binary for your platform.
@@ -44,12 +45,6 @@ Download the latest OpenCode Desktop build for your platform.
- **Linux (.rpm):** [x64](https://opencode.ai/update/api/latest/desktop/opencode/opencode-desktop-linux-x86_64.rpm) · [ARM64](https://opencode.ai/update/api/latest/desktop/opencode/opencode-desktop-linux-aarch64.rpm)
- **Linux (AppImage):** [x64](https://opencode.ai/update/api/latest/desktop/opencode/opencode-desktop-linux-x86_64.AppImage) · [ARM64](https://opencode.ai/update/api/latest/desktop/opencode/opencode-desktop-linux-arm64.AppImage)
On Windows, you can also install it with WinGet.
```powershell
winget install --id Anomaly.OpenCode.Desktop -e
```
## Web
+1 -2
View File
@@ -358,8 +358,7 @@ Rename the singular `provider` map to `providers`. V2 separates the runtime pack
```
V1 `npm` becomes `package`, and AI SDK packages receive the `aisdk:` prefix. `api` becomes `settings.baseURL`. Provider
`options` are separated into `settings`, `headers`, and `body` according to their request role. `options.setCacheKey`
becomes `compatibility.supportsPromptCacheKey` on the provider. See [Providers](/providers).
`options` are separated into `settings`, `headers`, and `body` according to their request role. See [Providers](/providers).
V2 consolidated two legacy provider namespaces:
@@ -61,7 +61,6 @@ The `providers` object is keyed by the provider ID used in model references, suc
| `env` | Ordered environment variable names that can provide the credential. |
| `package` | Runtime provider package. |
| `canonical` | Built-in provider ID whose catalog defaults this provider inherits. |
| `compatibility` | Request and response compatibility overrides for every model of this provider. Model `compatibility` overrides individual fields. `supportsThinkingBlockBinding` and `supportsEffortUpdates` are model-only. |
| `settings` | Typed OpenCode controls and JSON options passed to the runtime package. |
| `headers` | String-valued HTTP headers added to requests. |
| `body` | JSON fields merged into request bodies. |