mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 01:48:29 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80ddb32bdf | |||
| e666e47110 | |||
| ec10d71f22 | |||
| ca589273c7 | |||
| 75a979ec5c | |||
| d4f10fa9be | |||
| 08dd3f51ed | |||
| 0e99cb987a | |||
| 7731d1235d | |||
| 174c0a742c | |||
| b080f216a5 | |||
| 0d7fe6e074 | |||
| 01b7b53eeb | |||
| 613c570a3b | |||
| a48d44955e |
@@ -466,6 +466,7 @@ jobs:
|
||||
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
|
||||
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
|
||||
|
||||
- name: Package
|
||||
if: needs.version.outputs.release
|
||||
|
||||
@@ -30,7 +30,6 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "anthropic-messages"
|
||||
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
|
||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||
export const PATH = "/messages"
|
||||
|
||||
@@ -400,7 +399,7 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
|
||||
const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES)
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
if (media.mime === "application/pdf")
|
||||
return {
|
||||
type: "document" as const,
|
||||
@@ -410,6 +409,8 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
|
||||
data: media.base64,
|
||||
},
|
||||
} satisfies AnthropicDocumentBlock
|
||||
if (!media.mime.startsWith("image/"))
|
||||
return yield* invalid(`Anthropic Messages does not support media type ${part.mediaType}`)
|
||||
return {
|
||||
type: "image" as const,
|
||||
source: {
|
||||
|
||||
@@ -24,7 +24,6 @@ import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
|
||||
const ADAPTER = "gemini"
|
||||
const MEDIA_MIMES = new Set<string>(ProviderShared.MEDIA_MIMES)
|
||||
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
|
||||
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
@@ -248,7 +247,7 @@ const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
|
||||
const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPart | MediaPart) {
|
||||
if (part.type === "text") return { text: part.text }
|
||||
const media = yield* ProviderShared.validateMedia("Gemini", part, MEDIA_MIMES)
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return { inlineData: { mimeType: media.mime, data: media.base64 } }
|
||||
})
|
||||
|
||||
@@ -353,7 +352,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
const media: GeminiInlineDataPart[] = []
|
||||
for (const item of content) {
|
||||
if (item.type === "text") continue
|
||||
const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
|
||||
const value = ProviderShared.normalizeToolFile(item)
|
||||
media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
|
||||
}
|
||||
parts.push({
|
||||
|
||||
@@ -26,7 +26,6 @@ import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "open-responses"
|
||||
const NAME = "Open Responses"
|
||||
const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
|
||||
export const PATH = "/responses"
|
||||
|
||||
// =============================================================================
|
||||
@@ -285,7 +284,7 @@ export interface Extension {
|
||||
readonly name: string
|
||||
readonly lowerMedia?: (input: {
|
||||
readonly part: MediaPart
|
||||
readonly media: ProviderShared.ValidatedMedia
|
||||
readonly media: ProviderShared.NormalizedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
||||
@@ -380,13 +379,13 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
) {
|
||||
const media = yield* ProviderShared.validateMedia(extension.name, part, MEDIA_MIMES)
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const extended = extension.lowerMedia?.({ part, media, request })
|
||||
if (extended) return extended
|
||||
if (media.mime === "application/pdf") {
|
||||
if (!media.mime.startsWith("image/")) {
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? "document.pdf",
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
file_data: media.dataUrl,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "openai-chat"
|
||||
const IMAGE_MIMES = new Set<string>(ProviderShared.IMAGE_MIMES)
|
||||
const RESERVED_REASONING_FIELDS = new Set(["role", "content", "tool_calls"])
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/chat/completions"
|
||||
@@ -284,7 +283,9 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
|
||||
})
|
||||
|
||||
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
|
||||
const media = yield* ProviderShared.validateMedia("OpenAI Chat", part, IMAGE_MIMES)
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
if (!media.mime.startsWith("image/"))
|
||||
return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.mediaType}`)
|
||||
return { type: "image_url" as const, image_url: { url: media.dataUrl } }
|
||||
})
|
||||
|
||||
|
||||
@@ -155,59 +155,24 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate
|
||||
export const parseToolInput = (route: string, name: string, raw: string) =>
|
||||
parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`)
|
||||
|
||||
export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
|
||||
export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
|
||||
export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
|
||||
export const PDF_MIMES = ["application/pdf"] as const
|
||||
export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const
|
||||
export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
|
||||
export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
|
||||
|
||||
const base64Pattern = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||
|
||||
export interface ValidatedMedia {
|
||||
export interface NormalizedMedia {
|
||||
readonly mime: string
|
||||
readonly base64: string
|
||||
readonly dataUrl: string
|
||||
readonly bytes: Uint8Array
|
||||
}
|
||||
|
||||
export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function* (
|
||||
route: string,
|
||||
part: MediaPart,
|
||||
supportedMimes: ReadonlySet<string>,
|
||||
) {
|
||||
export const normalizeMedia = (part: MediaPart): NormalizedMedia => {
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
if (!supportedMimes.has(mime)) return yield* invalidRequest(`${route} does not support media type ${part.mediaType}`)
|
||||
|
||||
let base64: string
|
||||
if (typeof part.data !== "string") {
|
||||
if (part.data.byteLength > MAX_MEDIA_DECODED_BYTES)
|
||||
return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`)
|
||||
base64 = Buffer.from(part.data).toString("base64")
|
||||
} else if (part.data.startsWith("data:")) {
|
||||
const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]*={0,2})$/s.exec(part.data)
|
||||
if (!match) return yield* invalidRequest(`${route} media data URL must contain valid base64`)
|
||||
if (match[1]!.toLowerCase() !== mime)
|
||||
return yield* invalidRequest(`${route} media type ${part.mediaType} does not match data URL type ${match[1]}`)
|
||||
base64 = match[2]!
|
||||
} else {
|
||||
base64 = part.data
|
||||
const base64 = Buffer.from(part.data).toString("base64")
|
||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}` }
|
||||
}
|
||||
if (!part.data.startsWith("data:")) return { mime, base64: part.data, dataUrl: `data:${mime};base64,${part.data}` }
|
||||
return { mime, base64: part.data.slice(part.data.indexOf(",") + 1), dataUrl: part.data }
|
||||
}
|
||||
|
||||
if (Buffer.byteLength(base64, "utf8") > MAX_MEDIA_ENCODED_BYTES)
|
||||
return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_ENCODED_BYTES} byte encoded limit`)
|
||||
if (!base64 || base64.length % 4 !== 0 || !base64Pattern.test(base64))
|
||||
return yield* invalidRequest(`${route} media must contain valid base64`)
|
||||
const bytes = Buffer.from(base64, "base64")
|
||||
if (bytes.byteLength > MAX_MEDIA_DECODED_BYTES)
|
||||
return yield* invalidRequest(`${route} media exceeds the ${MAX_MEDIA_DECODED_BYTES} byte decoded limit`)
|
||||
if (bytes.toString("base64") !== base64) return yield* invalidRequest(`${route} media must contain canonical base64`)
|
||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
|
||||
})
|
||||
|
||||
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) =>
|
||||
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
|
||||
export const normalizeToolFile = (part: Tool.FileContent) =>
|
||||
normalizeMedia({ type: "media", mediaType: part.mime, data: part.uri, filename: part.name })
|
||||
|
||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||
|
||||
|
||||
@@ -66,11 +66,7 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
|
||||
if (imageFormat) {
|
||||
const media = yield* ProviderShared.validateMedia(
|
||||
"Bedrock Converse",
|
||||
part,
|
||||
new Set<string>(Object.keys(IMAGE_FORMATS)),
|
||||
)
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return { image: { format: imageFormat, source: { bytes: media.base64 } } } satisfies ImageBlock
|
||||
}
|
||||
if (mime.startsWith("image/"))
|
||||
@@ -79,11 +75,7 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
|
||||
if (documentFormat) {
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
const media = yield* ProviderShared.validateMedia(
|
||||
"Bedrock Converse",
|
||||
part,
|
||||
new Set<string>(Object.keys(DOCUMENT_FORMATS)),
|
||||
)
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
return documentBlock(part.filename, documentFormat, media.base64)
|
||||
}
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||
|
||||
@@ -399,7 +399,7 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
|
||||
it.effect("rejects tool-result media that cannot be lowered", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -418,8 +418,7 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Anthropic Messages")
|
||||
expect(error.message).toContain("audio/mpeg")
|
||||
expect(error.message).toContain("Anthropic Messages does not support media type audio/mpeg")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as Gemini from "../../src/protocols/gemini.js"
|
||||
import { ProviderShared } from "../../src/protocols/shared.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents, sseRaw } from "../lib/sse.js"
|
||||
@@ -291,35 +290,30 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [name, media] of [
|
||||
["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }],
|
||||
["malformed base64", { mediaType: "image/png", data: "%%%=" }],
|
||||
["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }],
|
||||
] as const)
|
||||
it.effect(`rejects ${name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.message).toMatch(/does not support|does not match|valid base64/)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized image input", () =>
|
||||
it.effect("passes encoded media through without local validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4),
|
||||
}),
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "image/png", data: "%%%=" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" },
|
||||
{ type: "media", mediaType: "image/svg+xml", data: "PHN2Zz4=" },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.message).toContain("encoded limit")
|
||||
)
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{ inlineData: { mimeType: "image/png", data: "%%%=" } },
|
||||
{ inlineData: { mimeType: "image/png", data: "/9j/" } },
|
||||
{ inlineData: { mimeType: "image/svg+xml", data: "PHN2Zz4=" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -527,35 +527,42 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [name, media] of [
|
||||
["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }],
|
||||
["malformed base64", { mediaType: "image/png", data: "not-base64" }],
|
||||
["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }],
|
||||
] as const)
|
||||
it.effect(`rejects ${name}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.message).toMatch(/does not support|does not match|valid base64/)
|
||||
}),
|
||||
)
|
||||
it.effect("passes encoded image media through without local validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "media", mediaType: "image/png", data: "not-base64" },
|
||||
{ type: "media", mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" },
|
||||
{ type: "media", mediaType: "image/svg+xml", data: "PHN2Zz4=" },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "image_url", image_url: { url: "data:image/png;base64,not-base64" } },
|
||||
{ type: "image_url", image_url: { url: "data:image/jpeg;base64,/9j/" } },
|
||||
{ type: "image_url", image_url: { url: "data:image/svg+xml;base64,PHN2Zz4=" } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects oversized image input", () =>
|
||||
it.effect("rejects non-image media that cannot be lowered", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4),
|
||||
}),
|
||||
],
|
||||
messages: [Message.user({ type: "media", mediaType: "audio/mpeg", data: "AAECAw==" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.message).toContain("encoded limit")
|
||||
expect(error.message).toContain("OpenAI Chat does not support media type audio/mpeg")
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1149,6 +1149,32 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes large PDF tool-result content through", () =>
|
||||
Effect.gen(function* () {
|
||||
const base64 = "A".repeat(8_125_844)
|
||||
const dataUrl = `data:application/pdf;base64,${base64}`
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result_large_pdf",
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
resultType: "content",
|
||||
result: [{ type: "file", uri: dataUrl, mime: "application/pdf", name: "report.pdf" }],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "report.pdf", file_data: dataUrl },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses xAI inline file encoding for PDF tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1184,9 +1210,9 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported media in tool-result content with a clear error", () =>
|
||||
it.effect("passes non-image tool-result content through as an input file", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_tool_result_unsupported_media",
|
||||
model,
|
||||
@@ -1200,10 +1226,11 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
)
|
||||
|
||||
expect(error.message).toContain("OpenAI Responses")
|
||||
expect(error.message).toContain("audio/mpeg")
|
||||
expect(expectToolOutput(prepared.body).output).toEqual([
|
||||
{ type: "input_file", filename: "file", file_data: "data:audio/mpeg;base64,AAECAw==" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -2394,17 +2421,28 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported user media content", () =>
|
||||
it.effect("passes non-image user media through as an input file", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
id: "req_media",
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
)
|
||||
|
||||
expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar")
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "file",
|
||||
file_data: "data:application/x-tar;base64,AAECAw==",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -60,7 +60,11 @@ export function NewSessionView(props: {
|
||||
<Show
|
||||
when={props.workspace.bar.visible()}
|
||||
fallback={
|
||||
<PromptGitStatus branch={props.workspace.bar.branch()} noGit={!props.workspace.project.git()} />
|
||||
<PromptGitStatus
|
||||
branch={props.workspace.bar.branch()}
|
||||
noGit={!props.workspace.project.git()}
|
||||
class="ms-1"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PromptWorkspaceSelector
|
||||
|
||||
@@ -5,7 +5,8 @@ const fs = require("fs")
|
||||
const path = require("path")
|
||||
const os = require("os")
|
||||
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM", "SIGHUP"]
|
||||
const forwardedSignals =
|
||||
process.platform === "win32" ? ["SIGINT", "SIGTERM", "SIGHUP"] : ["SIGINT", "SIGTERM", "SIGHUP", "SIGUSR1"]
|
||||
|
||||
function run(target) {
|
||||
const child = childProcess.spawn(target, process.argv.slice(2), { stdio: "inherit" })
|
||||
|
||||
@@ -117,9 +117,9 @@ for (const item of targets) {
|
||||
autoloadTsconfig: true,
|
||||
autoloadPackageJson: true,
|
||||
target: target.replace(binary, "bun") as Bun.Build.CompileTarget,
|
||||
executablePath,
|
||||
...(executablePath ? { executablePath } : {}),
|
||||
outfile: path.join(outdir, name, "bin", binary),
|
||||
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--"],
|
||||
execArgv: [`--user-agent=${binary}/${Script.version}`, "--use-system-ca", "--no-warnings", "--"],
|
||||
windows: {},
|
||||
},
|
||||
define: {
|
||||
|
||||
@@ -20,7 +20,6 @@ async function publish(dir: string, name: string, version: string) {
|
||||
await $`bun pm pack`.cwd(dir)
|
||||
await $`npm publish *.tgz --access public --tag ${Script.channel}`.cwd(dir)
|
||||
}
|
||||
if (Script.channel === "beta") await $`npm dist-tag add ${`${name}@${version}`} next`
|
||||
}
|
||||
|
||||
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Argument, Flag } from "effect/unstable/cli"
|
||||
import { Argument, Command, Flag } from "effect/unstable/cli"
|
||||
import { Spec } from "../framework/spec"
|
||||
import { GlobalFlags } from "./global-flags"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
@@ -26,7 +27,7 @@ const PermissionParams = {
|
||||
),
|
||||
}
|
||||
|
||||
export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME : "opencode", {
|
||||
description: "OpenCode 2.0 preview command line interface",
|
||||
params: {
|
||||
...ServerParams,
|
||||
@@ -70,7 +71,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
description: "Debugging and troubleshooting tools",
|
||||
commands: [
|
||||
Spec.make("agents", { description: "List all agents" }),
|
||||
Spec.make("config", { description: "Show resolved configuration" }),
|
||||
Spec.make("config", { description: "List configuration sources" }),
|
||||
],
|
||||
}),
|
||||
Spec.make("console", {
|
||||
@@ -277,3 +278,5 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export const Commands = { ...Root, spec: Root.spec.pipe(Command.withGlobalFlags(GlobalFlags.all)) }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export * as GlobalFlags from "./global-flags"
|
||||
|
||||
import { Flag, GlobalFlag } from "effect/unstable/cli"
|
||||
|
||||
export const CpuProfile = GlobalFlag.setting("cpu-profile")({
|
||||
flag: Flag.string("cpu-profile").pipe(
|
||||
Flag.withDescription("Write a CPU profile to this path when the process stops"),
|
||||
Flag.optional,
|
||||
),
|
||||
})
|
||||
|
||||
export const all = [CpuProfile] as const
|
||||
@@ -0,0 +1,45 @@
|
||||
export * as CpuProfile from "./cpu-profile"
|
||||
|
||||
import { Effect, FileSystem } from "effect"
|
||||
import { Session } from "node:inspector"
|
||||
import path from "node:path"
|
||||
|
||||
export function run<A, E, R>(file: string, effect: Effect.Effect<A, E, R>) {
|
||||
const target = path.resolve(file)
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
yield* fs.makeDirectory(path.dirname(target), { recursive: true })
|
||||
const session = new Session()
|
||||
session.connect()
|
||||
yield* command(session, "Profiler.enable")
|
||||
yield* command(session, "Profiler.start")
|
||||
yield* Effect.logInfo("CPU profile started", { path: target })
|
||||
return session
|
||||
}),
|
||||
() => effect,
|
||||
(session) =>
|
||||
Effect.tryPromise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
session.post("Profiler.stop", (error, result) => {
|
||||
session.disconnect()
|
||||
if (error) return reject(error)
|
||||
Bun.write(target, JSON.stringify(result.profile)).then(() => resolve(), reject)
|
||||
})
|
||||
}),
|
||||
).pipe(
|
||||
Effect.andThen(Effect.logInfo("CPU profile written", { path: target })),
|
||||
Effect.catchCause((cause) => Effect.logError("Failed to write CPU profile", { path: target, cause })),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function command(session: Session, method: "Profiler.enable" | "Profiler.start") {
|
||||
return Effect.tryPromise(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
session.post(method, (error) => (error ? reject(error) : resolve()))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Effect, FileSystem, Scope } from "effect"
|
||||
import { Effect, FileSystem, Option, Scope } from "effect"
|
||||
import { Command } from "effect/unstable/cli"
|
||||
import { Spec } from "./spec"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Updater } from "../services/updater"
|
||||
import { Config } from "../config"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { GlobalFlags } from "../commands/global-flags"
|
||||
import { CpuProfile } from "../cpu-profile"
|
||||
import path from "node:path"
|
||||
|
||||
export type Input<Value> =
|
||||
Value extends Spec.Node<infer _Name, infer Command, infer _Commands>
|
||||
@@ -86,7 +89,22 @@ function provide(node: Spec.Any, handlers: ReadonlyArray<LazyHandler>): Provided
|
||||
? node.spec.pipe(
|
||||
Command.withHandler((input) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.flatMap(Effect.promise(handler.load), (module) => module.default(input))
|
||||
const module = yield* Effect.promise(handler.load)
|
||||
const cpuProfile = Option.getOrUndefined(yield* GlobalFlags.CpuProfile)
|
||||
if (!cpuProfile) return yield* module.default(input)
|
||||
const target = path.resolve(cpuProfile)
|
||||
const previous = process.env.OPENCODE_CPU_PROFILE
|
||||
process.env.OPENCODE_CPU_PROFILE = target
|
||||
return yield* (
|
||||
node.name === "serve" ? CpuProfile.run(target, module.default(input)) : module.default(input)
|
||||
).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
|
||||
else process.env.OPENCODE_CPU_PROFILE = previous
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Effect, Queue } from "effect"
|
||||
import path from "node:path"
|
||||
|
||||
export const listen = Effect.gen(function* () {
|
||||
const global = yield* Global.Service
|
||||
if (process.platform === "win32") return
|
||||
const signals = yield* Queue.dropping<void>(1)
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const handler = () => Queue.offerUnsafe(signals, undefined)
|
||||
process.on("SIGUSR1", handler)
|
||||
return handler
|
||||
}),
|
||||
(handler) => Effect.sync(() => process.off("SIGUSR1", handler)),
|
||||
)
|
||||
yield* Queue.take(signals).pipe(
|
||||
Effect.andThen(
|
||||
Effect.suspend(() => {
|
||||
const file = path.join(
|
||||
global.log,
|
||||
`heap-${process.pid}-${new Date().toISOString().replace(/[:.]/g, "")}.heapsnapshot`,
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.logInfo("writing heap snapshot", { path: file })
|
||||
const { writeHeapSnapshot } = yield* Effect.tryPromise(() => import("node:v8"))
|
||||
yield* Effect.try(() => writeHeapSnapshot(file))
|
||||
yield* Effect.logInfo("heap snapshot written", { path: file })
|
||||
}).pipe(Effect.catchCause((cause) => Effect.logError("failed to write heap snapshot", { path: file, cause })))
|
||||
}),
|
||||
),
|
||||
Effect.forever,
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
})
|
||||
|
||||
export * as Heap from "./heap"
|
||||
@@ -12,6 +12,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Config } from "./config"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Heap } from "./heap"
|
||||
|
||||
const Handlers = Runtime.handlers(Commands, {
|
||||
$: () => import("./commands/handlers/default"),
|
||||
@@ -54,13 +55,16 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
serve: () => import("./commands/handlers/serve"),
|
||||
})
|
||||
|
||||
Effect.logInfo("cli starting", {
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
local: OPENCODE_LOCAL,
|
||||
args: process.argv.slice(2),
|
||||
Effect.gen(function* () {
|
||||
yield* Heap.listen
|
||||
yield* Effect.logInfo("cli starting", {
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
local: OPENCODE_LOCAL,
|
||||
args: process.argv.slice(2),
|
||||
})
|
||||
return yield* Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })
|
||||
}).pipe(
|
||||
Effect.flatMap(() => Runtime.run(Commands, Handlers, { version: OPENCODE_VERSION })),
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
|
||||
@@ -104,7 +104,12 @@ export const options = Effect.fnUntraced(function* (input: { readonly checkVersi
|
||||
return {
|
||||
file,
|
||||
version: input.checkVersion ? OPENCODE_VERSION : undefined,
|
||||
command: [...selfCommand(), "serve", "--service"],
|
||||
command: [
|
||||
...selfCommand(),
|
||||
"serve",
|
||||
"--service",
|
||||
...(process.env.OPENCODE_CPU_PROFILE ? ["--cpu-profile", process.env.OPENCODE_CPU_PROFILE] : []),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ describe("debug config command", () => {
|
||||
|
||||
expect(debug.exitCode).toBe(0)
|
||||
expect(debug.stdout).toContain("config")
|
||||
expect(debug.stdout).toContain("Show resolved configuration")
|
||||
expect(debug.stdout).toContain("List configuration sources")
|
||||
expect(config.exitCode).toBe(0)
|
||||
expect(config.stdout).toContain("opencode debug config [flags]")
|
||||
expect(config.stdout).toContain("List configuration sources")
|
||||
})
|
||||
|
||||
test("prints config entries from the invoking directory without reordering permissions", async () => {
|
||||
|
||||
@@ -19,6 +19,29 @@ test("managed service ports are stable per installation channel", () => {
|
||||
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
|
||||
})
|
||||
|
||||
test("managed service forwards the CPU profile path to the server", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-profile-"))
|
||||
const profile = path.join(root, "server.cpuprofile")
|
||||
try {
|
||||
const previous = process.env.OPENCODE_CPU_PROFILE
|
||||
process.env.OPENCODE_CPU_PROFILE = profile
|
||||
try {
|
||||
const options = await Effect.runPromise(
|
||||
ServiceConfig.options().pipe(
|
||||
Effect.provide(Global.layerWith({ config: path.join(root, "config"), state: path.join(root, "state") })),
|
||||
Effect.provide(NodeFileSystem.layer),
|
||||
),
|
||||
)
|
||||
expect(options.command.slice(-2)).toEqual(["--cpu-profile", profile])
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.OPENCODE_CPU_PROFILE
|
||||
else process.env.OPENCODE_CPU_PROFILE = previous
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test("local channel stores service config with the local service filename", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-"))
|
||||
try {
|
||||
|
||||
@@ -435,7 +435,22 @@ function prompt(request: LLMRequest): LanguageModelV3Prompt {
|
||||
.map((part) => part.text)
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
const messages = request.messages.flatMap(message)
|
||||
const pending: UserContent = []
|
||||
const messages = request.messages.flatMap((input, index) => {
|
||||
if (input.role !== "tool") return message(input)
|
||||
const lowered = toolMessage(input)
|
||||
pending.push(...lowered.media)
|
||||
if (request.messages[index + 1]?.role === "tool" || pending.length === 0) return lowered.messages
|
||||
const media = [...pending]
|
||||
pending.length = 0
|
||||
return [
|
||||
...lowered.messages,
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "Attached media from tool result:" }, ...media],
|
||||
},
|
||||
]
|
||||
})
|
||||
if (!system.length) return messages
|
||||
return [{ role: "system", content: system }, ...messages]
|
||||
}
|
||||
@@ -448,10 +463,33 @@ function message(input: LLMRequest["messages"][number]): LanguageModelV3Message[
|
||||
return [{ role: "user", content: input.content.flatMap(userPart) }]
|
||||
case "assistant":
|
||||
return [{ role: "assistant", content: input.content.flatMap(assistantPart) }]
|
||||
case "tool": {
|
||||
const content = input.content.flatMap(toolResultPart)
|
||||
return content.length ? [{ role: "tool", content }] : []
|
||||
}
|
||||
case "tool":
|
||||
return toolMessage(input).messages
|
||||
}
|
||||
}
|
||||
|
||||
function toolMessage(input: LLMRequest["messages"][number]) {
|
||||
const media: UserContent = []
|
||||
const content = input.content.flatMap((part) => {
|
||||
if (part.type !== "tool-result" || part.result.type !== "content") return toolResultPart(part)
|
||||
const value = part.result.value.filter((item) => {
|
||||
if (item.type !== "file") return true
|
||||
if (!item.mime.startsWith("image/") && item.mime !== "application/pdf") return true
|
||||
const data = /^data:[^;,]+(?:;[^,]*)*;base64,(.*)$/s.exec(item.uri)?.[1] ?? item.uri
|
||||
media.push({ type: "file", mediaType: item.mime, data, filename: item.name })
|
||||
return false
|
||||
})
|
||||
return toolResultPart({
|
||||
...part,
|
||||
result:
|
||||
value.length === 0
|
||||
? { type: "text", value: "Media attached in the following user message." }
|
||||
: { ...part.result, value },
|
||||
})
|
||||
})
|
||||
return {
|
||||
messages: content.length ? ([{ role: "tool", content }] satisfies LanguageModelV3Message[]) : [],
|
||||
media,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PlanPlugin from "./plan.js"
|
||||
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Message, ToolFailure } from "@opencode-ai/ai"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { Agent } from "../agent.js"
|
||||
@@ -38,13 +38,33 @@ export const Plugin = define({
|
||||
})
|
||||
})
|
||||
|
||||
// Compaction and committed reverts can strip reminders while the session's agent stays
|
||||
// put. Reconcile per request, appending near the tail so the cached prefix stays warm.
|
||||
yield* ctx.session.hook("context", (event) => {
|
||||
const reminder = lastReminder(event.messages)
|
||||
const missing = event.agent === plan && reminder !== enter
|
||||
const stale = event.agent !== plan && reminder === enter
|
||||
const text = missing ? enter : stale ? leave : undefined
|
||||
if (!text) return Effect.void
|
||||
// Before the user's prompt, matching where agent-switch reminders land.
|
||||
const at = event.messages.at(-1)?.role === "user" ? event.messages.length - 1 : event.messages.length
|
||||
event.messages.splice(at, 0, Message.user(text))
|
||||
return ctx.session
|
||||
.synthetic({ sessionID: event.sessionID, text, resume: false })
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to persist Plan mode reminder", { sessionID: event.sessionID, cause }),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter(
|
||||
(event): event is SessionEvent.Created | SessionEvent.AgentSelected =>
|
||||
event.type === "session.created" || event.type === "session.agent.selected",
|
||||
),
|
||||
Stream.runForEach((event) => {
|
||||
const text = reminder(event)
|
||||
const text = switchReminder(event)
|
||||
if (!text) return Effect.void
|
||||
return ctx.session
|
||||
.synthetic({
|
||||
@@ -63,7 +83,7 @@ export const Plugin = define({
|
||||
}),
|
||||
})
|
||||
|
||||
function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
|
||||
function switchReminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
|
||||
if (event.type === "session.created") {
|
||||
if (event.data.agent !== plan) return
|
||||
return enter
|
||||
@@ -72,3 +92,11 @@ function reminder(event: SessionEvent.Created | SessionEvent.AgentSelected) {
|
||||
if (event.data.agent === plan) return enter
|
||||
if (event.data.previous === plan) return leave
|
||||
}
|
||||
|
||||
function lastReminder(messages: ReadonlyArray<Message>) {
|
||||
return messages.reduce<string | undefined>((found, message) => {
|
||||
const part = message.role === "user" && message.content.length === 1 ? message.content[0] : undefined
|
||||
if (part?.type !== "text") return found
|
||||
return part.text === enter || part.text === leave ? part.text : found
|
||||
}, undefined)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ import { GroqPlugin } from "./provider/groq.js"
|
||||
import { KiloPlugin } from "./provider/kilo.js"
|
||||
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
||||
import { MistralPlugin } from "./provider/mistral.js"
|
||||
import { ModalPlugin } from "./provider/modal.js"
|
||||
import { NvidiaPlugin } from "./provider/nvidia.js"
|
||||
import { OpenAIPlugin } from "./provider/openai.js"
|
||||
import { SnowflakeCortexPlugin } from "./provider/snowflake-cortex.js"
|
||||
@@ -50,7 +49,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
KiloPlugin,
|
||||
LLMGatewayPlugin,
|
||||
MistralPlugin,
|
||||
ModalPlugin,
|
||||
NvidiaPlugin,
|
||||
OpencodePlugin,
|
||||
SnowflakeCortexPlugin,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Effect, Schema, Semaphore, Stream } from "effect"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Catalog } from "../../catalog.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
const providerID = Provider.ID.make("modal")
|
||||
|
||||
const ReasoningOption = Schema.Struct({
|
||||
type: Schema.Literal("effort"),
|
||||
values: Schema.Array(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
|
||||
const Response = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
base_model_id: Schema.optional(Schema.String),
|
||||
hugging_face_id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
input_modalities: Schema.optional(Schema.Array(Schema.String)),
|
||||
output_modalities: Schema.optional(Schema.Array(Schema.String)),
|
||||
context_length: Schema.optional(Schema.Number),
|
||||
max_output_length: Schema.optional(Schema.Number),
|
||||
pricing: Schema.optional(
|
||||
Schema.Struct({
|
||||
prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])),
|
||||
}),
|
||||
),
|
||||
supported_features: Schema.optional(Schema.Array(Schema.String)),
|
||||
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
|
||||
interleaved: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Boolean,
|
||||
Schema.Struct({ field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]) }),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const decode = Schema.decodeUnknownSync(Response)
|
||||
|
||||
export const ModalPlugin = define({
|
||||
id: "opencode.provider.modal",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const bus = yield* Bus.Service
|
||||
const catalog = yield* Catalog.Service
|
||||
const loading = Semaphore.makeUnsafe(1)
|
||||
let templates: Map<Model.ID, Model.Info> | undefined
|
||||
let models: Map<Model.ID, Model.Info> | undefined
|
||||
|
||||
const load = Effect.fn("ModalPlugin.load")(function* () {
|
||||
const existing =
|
||||
templates ??
|
||||
new Map(
|
||||
(yield* catalog.model.all())
|
||||
.filter((model) => model.providerID === providerID)
|
||||
.map((model) => [model.id, model]),
|
||||
)
|
||||
templates = existing
|
||||
const connection = yield* ctx.integration.connection.active("modal")
|
||||
const credential = connection
|
||||
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
const provider = yield* catalog.provider.get(providerID)
|
||||
const baseURL = typeof provider?.settings?.baseURL === "string" ? provider.settings.baseURL : undefined
|
||||
if (credential?.type !== "key" || !baseURL) {
|
||||
models = new Map()
|
||||
return
|
||||
}
|
||||
|
||||
models = yield* Effect.tryPromise({
|
||||
try: () => discover(baseURL, credential.key, existing),
|
||||
catch: (cause) => cause,
|
||||
}).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("failed to sync Modal models", { cause }).pipe(Effect.as(new Map<Model.ID, Model.Info>())),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
yield* ctx.catalog.transform((draft) => {
|
||||
if (!models) return
|
||||
const provider = draft.provider.get(providerID)
|
||||
if (!provider) return
|
||||
for (const id of provider.models.keys()) {
|
||||
if (!models.has(Model.ID.make(id))) draft.model.remove(providerID, Model.ID.make(id))
|
||||
}
|
||||
for (const [id, model] of models) {
|
||||
draft.model.update(providerID, id, (item) => Object.assign(item, structuredClone(model)))
|
||||
}
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("modal")),
|
||||
Stream.runForEach(refresh),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh().pipe(Effect.forkScoped)
|
||||
}),
|
||||
})
|
||||
|
||||
async function discover(baseURL: string, apiKey: string, templates: ReadonlyMap<Model.ID, Model.Info>) {
|
||||
const response = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(3_000),
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to fetch Modal models: ${response.status}`)
|
||||
|
||||
return new Map(
|
||||
decode(await response.json()).data.map((item) => {
|
||||
const id = Model.ID.make(item.id)
|
||||
const template = templates.get(Model.ID.make(item.base_model_id ?? item.hugging_face_id ?? item.id))
|
||||
return [id, build(id, item, baseURL, template)]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function build(id: Model.ID, item: (typeof Response.Type)["data"][number], baseURL: string, template?: Model.Info) {
|
||||
const fallback: Model.Info = template ?? Model.Info.make(Model.Info.default(providerID, id))
|
||||
const baseCost = fallback.cost[0]
|
||||
const variants = item.reasoning_options?.flatMap((option) =>
|
||||
option.values.map((value) => {
|
||||
const effort = value ?? "none"
|
||||
return {
|
||||
id: Model.VariantID.make(effort),
|
||||
settings: { reasoningEffort: effort },
|
||||
}
|
||||
}),
|
||||
)
|
||||
return Model.Info.make({
|
||||
...structuredClone(fallback),
|
||||
id,
|
||||
modelID: id,
|
||||
providerID,
|
||||
name: item.name ?? fallback.name,
|
||||
compatibility: Model.compatibility(item.interleaved) ?? fallback.compatibility,
|
||||
package: fallback.package ?? Provider.aisdk("@ai-sdk/openai-compatible"),
|
||||
settings: Provider.mergeOverlay(fallback.settings, { baseURL }),
|
||||
capabilities: {
|
||||
tools: item.supported_features?.includes("tools") ?? fallback.capabilities.tools,
|
||||
input: item.input_modalities ? [...item.input_modalities] : [...fallback.capabilities.input],
|
||||
output: item.output_modalities ? [...item.output_modalities] : [...fallback.capabilities.output],
|
||||
},
|
||||
variants: variants ?? [...fallback.variants],
|
||||
cost: [
|
||||
{
|
||||
input: price(item.pricing?.prompt, baseCost?.input ?? Money.USDPerMillionTokens.zero),
|
||||
output: price(item.pricing?.completion, baseCost?.output ?? Money.USDPerMillionTokens.zero),
|
||||
cache: {
|
||||
read: price(item.pricing?.input_cache_read, baseCost?.cache.read ?? Money.USDPerMillionTokens.zero),
|
||||
write: baseCost?.cache.write ?? Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
],
|
||||
limit: {
|
||||
context: item.context_length ?? fallback.limit.context,
|
||||
input: fallback.limit.input,
|
||||
output: item.max_output_length ?? fallback.limit.output,
|
||||
},
|
||||
status: fallback.status,
|
||||
enabled: fallback.enabled,
|
||||
})
|
||||
}
|
||||
|
||||
function price(value: string | number | undefined, fallback: number) {
|
||||
if (value === undefined) return Money.USDPerMillionTokens.make(fallback)
|
||||
const parsed = Number(value)
|
||||
return Money.USDPerMillionTokens.make(Number.isFinite(parsed) ? parsed * 1_000_000 : fallback)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { APICallError } from "@ai-sdk/provider"
|
||||
import type { LanguageModelV3, LanguageModelV3StreamPart } from "@ai-sdk/provider"
|
||||
import { createMistral } from "@ai-sdk/mistral"
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { SessionRunnerRetry } from "@opencode-ai/core/session/runner/retry"
|
||||
import { toSessionError } from "@opencode-ai/core/session/to-session-error"
|
||||
@@ -277,67 +278,88 @@ it.effect("projects replay metadata onto AI SDK prompt parts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves tool result content in AI SDK prompts", () =>
|
||||
it.effect("moves a tool image through the real Mistral provider as a user message", () =>
|
||||
Effect.gen(function* () {
|
||||
const aisdk = yield* AISDK.Service
|
||||
let body: { messages?: unknown[] } | undefined
|
||||
const mockFetch = Object.assign(
|
||||
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
|
||||
body = JSON.parse(String(init?.body))
|
||||
const chunks = [
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "pixtral-large-latest",
|
||||
choices: [{ index: 0, delta: { content: [{ type: "text", text: "I see it." }] } }],
|
||||
},
|
||||
{
|
||||
id: "response-1",
|
||||
created: 0,
|
||||
model: "pixtral-large-latest",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
},
|
||||
]
|
||||
return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), {
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
{ preconnect: fetch.preconnect },
|
||||
)
|
||||
yield* aisdk.hook.sdk((event) => {
|
||||
event.sdk = { languageModel: () => ({ provider: event.model.providerID }) }
|
||||
event.sdk = createMistral({ apiKey: "test", fetch: mockFetch })
|
||||
})
|
||||
|
||||
const resolved = yield* aisdk.model(model("test-ai-sdk"))
|
||||
const prepared = yield* compileRequest(
|
||||
const resolved = yield* aisdk.model({
|
||||
...model("@ai-sdk/mistral"),
|
||||
modelID: Model.ID.make("pixtral-large-latest"),
|
||||
})
|
||||
yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: resolved,
|
||||
messages: [
|
||||
Message.user("Inspect the screenshot."),
|
||||
Message.assistant({ type: "tool-call", id: "call_1", name: "screenshot", input: {} }),
|
||||
Message.tool({
|
||||
type: "tool-result",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
name: "screenshot",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "pixel.png" },
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:application/pdf;charset=utf-8;base64,JVBERg==",
|
||||
mime: "application/pdf",
|
||||
name: "document.pdf",
|
||||
},
|
||||
{ type: "file", uri: "data:audio/mpeg;base64,SUQz", mime: "audio/mpeg", name: "clip.mp3" },
|
||||
{ type: "file", uri: "https://example.com/pixel.png", mime: "image/png" },
|
||||
{ type: "file", uri: "https://example.com/document.pdf", mime: "application/pdf" },
|
||||
{ type: "text", text: "Screenshot captured" },
|
||||
{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png", name: "screen.png" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
).pipe(Effect.provide(client))
|
||||
|
||||
expect(prepared.body.prompt).toEqual([
|
||||
expect(body?.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Inspect the screenshot." }] },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "screenshot", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
name: "screenshot",
|
||||
tool_call_id: "call_1",
|
||||
content: '[{"type":"text","text":"Screenshot captured"}]',
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "read",
|
||||
output: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "attachments" },
|
||||
{ type: "image-data", data: "AAAA", mediaType: "image/png" },
|
||||
{
|
||||
type: "file-data",
|
||||
data: "JVBERg==",
|
||||
mediaType: "application/pdf",
|
||||
filename: "document.pdf",
|
||||
},
|
||||
{ type: "file-data", data: "SUQz", mediaType: "audio/mpeg", filename: "clip.mp3" },
|
||||
{ type: "image-url", url: "https://example.com/pixel.png" },
|
||||
{ type: "file-url", url: "https://example.com/document.pdf" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "Attached media from tool result:" },
|
||||
{ type: "image_url", image_url: "data:image/png;base64,AAAA" },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { DateTime, Effect, Stream } from "effect"
|
||||
import type { SessionContext } from "@opencode-ai/plugin/effect/session"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { PlanPlugin } from "@opencode-ai/core/plugin/plan"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
const sessionID = Session.ID.make("ses_plan_test")
|
||||
const plan = Agent.ID.make("plan")
|
||||
const build = Agent.ID.make("build")
|
||||
|
||||
const agentSelected = (agent: Agent.ID, previous: Agent.ID): SessionEvent.AgentSelected => ({
|
||||
id: Event.ID.create(),
|
||||
created: 0,
|
||||
durable: { aggregateID: sessionID, seq: Event.Seq.make(0), version: Event.Version.make(1) },
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID, agent, previous },
|
||||
})
|
||||
|
||||
/** Runs the plan plugin against stubbed domains, capturing persisted reminders and the context hook. */
|
||||
const run = Effect.fnUntraced(function* (events: ReadonlyArray<SessionEvent.AgentSelected> = []) {
|
||||
const persisted = new Array<string>()
|
||||
let contextHook: ((input: SessionContext) => Effect.Effect<void>) | undefined
|
||||
yield* PlanPlugin.Plugin.effect(
|
||||
host({
|
||||
agent: {
|
||||
get: () => Effect.die("unused agent.get"),
|
||||
list: () => Effect.die("unused agent.list"),
|
||||
reload: () => Effect.die("unused agent.reload"),
|
||||
transform: () => Effect.succeed({ dispose: Effect.void }),
|
||||
},
|
||||
tool: {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
},
|
||||
event: {
|
||||
subscribe: () => Stream.fromIterable(events),
|
||||
},
|
||||
session: {
|
||||
hook: (name, callback) => {
|
||||
if (name === "context") contextHook = callback as (input: SessionContext) => Effect.Effect<void>
|
||||
return Effect.succeed({ dispose: Effect.void })
|
||||
},
|
||||
synthetic: (input) => {
|
||||
persisted.push(input.text)
|
||||
return Effect.succeed(
|
||||
SessionInbox.Synthetic.make({
|
||||
id: SessionMessage.ID.make("msg_plan_test"),
|
||||
sessionID,
|
||||
timeCreated: DateTime.makeUnsafe(0),
|
||||
type: "synthetic",
|
||||
payload: { text: input.text },
|
||||
delivery: "steer",
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (!contextHook) return yield* Effect.die("plan plugin did not register a context hook")
|
||||
return { persisted, contextHook }
|
||||
})
|
||||
|
||||
const request = (agent: Agent.ID, messages: Array<Message>): SessionContext => ({
|
||||
sessionID,
|
||||
agent,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test") },
|
||||
system: [],
|
||||
messages,
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const settle = (persisted: ReadonlyArray<string>, expected: number, remaining = 1000): Effect.Effect<void, Error> =>
|
||||
Effect.gen(function* () {
|
||||
if (persisted.length >= expected) return
|
||||
if (remaining === 0) {
|
||||
return yield* Effect.fail(new Error(`Timed out waiting for ${expected} reminders, saw ${persisted.length}`))
|
||||
}
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
yield* settle(persisted, expected, remaining - 1)
|
||||
})
|
||||
|
||||
/** The exact reminder texts, derived from plugin behavior rather than duplicated here. */
|
||||
const reminders = Effect.gen(function* () {
|
||||
const planRun = yield* run()
|
||||
yield* planRun.contextHook(request(plan, []))
|
||||
const buildRun = yield* run()
|
||||
yield* buildRun.contextHook(request(build, [Message.user(planRun.persisted[0]!)]))
|
||||
return { enter: planRun.persisted[0]!, leave: buildRun.persisted[0]! }
|
||||
})
|
||||
|
||||
describe("plan plugin reminders", () => {
|
||||
it.effect("injects enter and leave reminders on agent switches", () =>
|
||||
Effect.gen(function* () {
|
||||
const { persisted } = yield* run([agentSelected(plan, build), agentSelected(build, plan)])
|
||||
yield* settle(persisted, 2)
|
||||
expect(persisted[0]).toContain("You are in Plan mode")
|
||||
expect(persisted[1]).toContain("NO LONGER in Plan mode")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles a missing enter reminder into the request and persists it", () =>
|
||||
Effect.gen(function* () {
|
||||
const { persisted, contextHook } = yield* run()
|
||||
const messages = [Message.user("what agent are you?")]
|
||||
yield* contextHook(request(plan, messages))
|
||||
expect(messages).toHaveLength(2)
|
||||
// Inserted before the user's prompt, matching where agent-switch reminders land.
|
||||
const first = messages[0]?.content[0]
|
||||
expect(first?.type === "text" && first.text).toContain("You are in Plan mode")
|
||||
expect(persisted).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does nothing when the transcript already has a live enter reminder", () =>
|
||||
Effect.gen(function* () {
|
||||
const { enter } = yield* reminders
|
||||
const { persisted, contextHook } = yield* run()
|
||||
const messages = [Message.user(enter), Message.user("hello")]
|
||||
yield* contextHook(request(plan, messages))
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(persisted).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reconciles a stale enter reminder with a leave reminder", () =>
|
||||
Effect.gen(function* () {
|
||||
const { enter } = yield* reminders
|
||||
const { persisted, contextHook } = yield* run()
|
||||
const messages = [Message.user(enter), Message.user("ok implement it")]
|
||||
yield* contextHook(request(build, messages))
|
||||
expect(messages).toHaveLength(3)
|
||||
const middle = messages[1]?.content[0]
|
||||
expect(middle?.type === "text" && middle.text).toContain("NO LONGER in Plan mode")
|
||||
expect(persisted).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does nothing for non-plan sessions without plan history", () =>
|
||||
Effect.gen(function* () {
|
||||
const { persisted, contextHook } = yield* run()
|
||||
const messages = [Message.user("hello")]
|
||||
yield* contextHook(request(build, messages))
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(persisted).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does nothing when a leave reminder already follows the enter reminder", () =>
|
||||
Effect.gen(function* () {
|
||||
const { enter, leave } = yield* reminders
|
||||
const { persisted, contextHook } = yield* run()
|
||||
const messages = [Message.user(enter), Message.user(leave), Message.user("continue")]
|
||||
yield* contextHook(request(build, messages))
|
||||
expect(messages).toHaveLength(3)
|
||||
expect(persisted).toHaveLength(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats reminder text quoted inside a larger message as not live", () =>
|
||||
Effect.gen(function* () {
|
||||
const { enter } = yield* reminders
|
||||
const { persisted, contextHook } = yield* run()
|
||||
// Mirrors a compaction checkpoint quoting the reminder inside <recent-context>.
|
||||
const messages = [Message.user(`<conversation-checkpoint>\n${enter}\n</conversation-checkpoint>`)]
|
||||
yield* contextHook(request(plan, messages))
|
||||
expect(messages).toHaveLength(2)
|
||||
expect(persisted).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,143 +0,0 @@
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { Integration } from "@opencode-ai/core/integration"
|
||||
import { Model } from "@opencode-ai/core/model"
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { ModalPlugin } from "@opencode-ai/core/plugin/provider/modal"
|
||||
import { Provider } from "@opencode-ai/core/provider"
|
||||
import { State } from "@opencode-ai/core/state"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const providerID = Provider.ID.make("modal")
|
||||
const integrationID = Integration.ID.make("modal")
|
||||
const baseModelID = Model.ID.make("thinkingmachines/Inkling-NVFP4")
|
||||
const runtimeModelID = Model.ID.make("workspace--inkling.us-west.modal.direct")
|
||||
|
||||
function eventually<A>(
|
||||
effect: Effect.Effect<A>,
|
||||
predicate: (value: A) => boolean,
|
||||
remaining = 1000,
|
||||
): Effect.Effect<A, Error> {
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* effect
|
||||
if (predicate(value)) return value
|
||||
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
|
||||
yield* Effect.promise(() => Bun.sleep(1))
|
||||
return yield* eventually(effect, predicate, remaining - 1)
|
||||
})
|
||||
}
|
||||
|
||||
const setup = Effect.fn(function* (baseURL: string, key?: string) {
|
||||
const catalog = yield* Catalog.Service
|
||||
const integrations = yield* Integration.Service
|
||||
yield* integrations.transform((draft) => {
|
||||
draft.method.update({ integrationID, method: { type: "key" } })
|
||||
})
|
||||
if (key) yield* integrations.connection.key({ integrationID, key })
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
yield* catalog.transform((draft) => {
|
||||
draft.provider.update(providerID, (provider) => {
|
||||
provider.name = "Modal"
|
||||
provider.package = Provider.aisdk("@ai-sdk/openai-compatible")
|
||||
provider.settings = { baseURL }
|
||||
provider.integrationID = integrationID
|
||||
})
|
||||
draft.model.update(providerID, baseModelID, (model) => {
|
||||
model.name = "Inkling"
|
||||
model.family = Model.Family.make("ling")
|
||||
model.compatibility = { reasoningField: "reasoning_content" }
|
||||
model.capabilities = { tools: true, input: ["text", "image", "audio"], output: ["text"] }
|
||||
model.variants = [{ id: Model.VariantID.make("fallback"), settings: { reasoningEffort: "fallback" } }]
|
||||
model.cost = [
|
||||
{
|
||||
input: Money.USDPerMillionTokens.make(1),
|
||||
output: Money.USDPerMillionTokens.make(4),
|
||||
cache: {
|
||||
read: Money.USDPerMillionTokens.make(0.2),
|
||||
write: Money.USDPerMillionTokens.zero,
|
||||
},
|
||||
},
|
||||
]
|
||||
model.limit = { context: 128_000, output: 8_192 }
|
||||
model.time = { released: Date.parse("2026-07-15") }
|
||||
})
|
||||
})
|
||||
yield* ModalPlugin.effect(yield* PluginHost.make(yield* Plugin.Service))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("discovers Modal workspace models", () =>
|
||||
Effect.gen(function* () {
|
||||
const requests: Array<{ authorization: string | null; path: string }> = []
|
||||
using server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
requests.push({ authorization: request.headers.get("authorization"), path: new URL(request.url).pathname })
|
||||
return Response.json({
|
||||
data: [
|
||||
{
|
||||
id: runtimeModelID,
|
||||
base_model_id: baseModelID,
|
||||
name: "Thinking Machines: Inkling",
|
||||
input_modalities: ["text", "image", "audio"],
|
||||
output_modalities: ["text"],
|
||||
context_length: 1_048_576,
|
||||
max_output_length: 262_144,
|
||||
pricing: { prompt: "0.0000012", completion: "0.000005", input_cache_read: "0.00000027" },
|
||||
supported_features: ["tools", "reasoning"],
|
||||
reasoning_options: [{ type: "effort", values: ["none", "low", "high"] }],
|
||||
interleaved: { field: "reasoning_content" },
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
yield* setup(`${server.url}v1`, "test-token")
|
||||
|
||||
const models = yield* eventually(
|
||||
(yield* Catalog.Service).model
|
||||
.all()
|
||||
.pipe(Effect.map((models) => models.filter((model) => model.providerID === providerID))),
|
||||
(models) => models.some((model) => model.id === runtimeModelID),
|
||||
)
|
||||
expect(requests).toEqual([{ authorization: "Bearer test-token", path: "/v1/models" }])
|
||||
expect(models).toHaveLength(1)
|
||||
expect(models[0]).toMatchObject({
|
||||
id: runtimeModelID,
|
||||
modelID: runtimeModelID,
|
||||
name: "Thinking Machines: Inkling",
|
||||
family: "ling",
|
||||
compatibility: { reasoningField: "reasoning_content" },
|
||||
settings: { baseURL: `${server.url}v1` },
|
||||
capabilities: { tools: true, input: ["text", "image", "audio"], output: ["text"] },
|
||||
variants: [
|
||||
{ id: "none", settings: { reasoningEffort: "none" } },
|
||||
{ id: "low", settings: { reasoningEffort: "low" } },
|
||||
{ id: "high", settings: { reasoningEffort: "high" } },
|
||||
],
|
||||
cost: [{ input: 1.2, output: 5, cache: { read: 0.27, write: 0 } }],
|
||||
limit: { context: 1_048_576, output: 262_144 },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("hides static Modal models when discovery fails", () =>
|
||||
Effect.gen(function* () {
|
||||
using server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 503 }) })
|
||||
yield* setup(`${server.url}v1`, "test-token")
|
||||
const models = yield* eventually(
|
||||
(yield* Catalog.Service).model
|
||||
.all()
|
||||
.pipe(Effect.map((models) => models.filter((model) => model.providerID === providerID))),
|
||||
(models) => models.length === 0,
|
||||
)
|
||||
expect(models).toEqual([])
|
||||
}),
|
||||
)
|
||||
@@ -126,6 +126,15 @@ export const settings: Setting[] = [
|
||||
values: ["horizontal", "vertical"],
|
||||
keywords: ["sidebar", "orientation", "left"],
|
||||
},
|
||||
{
|
||||
title: "Shortcut numbers",
|
||||
category: "Tabs",
|
||||
path: ["tabs", "numbers"],
|
||||
default: false,
|
||||
values: [false, true],
|
||||
labels: ["off", "on"],
|
||||
keywords: ["keys", "numeric", "labels"],
|
||||
},
|
||||
{
|
||||
title: "Layout",
|
||||
category: "Diffs",
|
||||
|
||||
@@ -38,6 +38,7 @@ import { projectName } from "../util/project"
|
||||
import { marqueeCycleWidth, marqueeOverflows, marqueeTextParts } from "../util/marquee"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSessionRename } from "./dialog-session-rename"
|
||||
import { Spinner } from "./spinner"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
const FADE_WIDTH = 4
|
||||
@@ -658,14 +659,23 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={1} paddingRight={1}>
|
||||
<text
|
||||
width={numberWidth()}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
<Show
|
||||
when={!config.tabs.numbers && runs()}
|
||||
fallback={
|
||||
<text
|
||||
width={numberWidth()}
|
||||
fg={numberColor()}
|
||||
selectable={false}
|
||||
attributes={selected() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{config.tabs.numbers ? sessionTabShortcutLabel(index()) : ""}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{sessionTabShortcutLabel(index())}
|
||||
</text>
|
||||
<box width={numberWidth()}>
|
||||
<Spinner color={numberColor()} />
|
||||
</box>
|
||||
</Show>
|
||||
<text
|
||||
width={titleWidth()}
|
||||
fg={foreground()}
|
||||
@@ -1144,9 +1154,22 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
|
||||
<text width={1} selectable={false}>
|
||||
{" "}
|
||||
</text>
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
|
||||
</text>
|
||||
<Show
|
||||
when={tab !== NEW_SESSION_TAB && !config.tabs.numbers && runs()}
|
||||
fallback={
|
||||
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
|
||||
{tab === NEW_SESSION_TAB
|
||||
? "+"
|
||||
: config.tabs.numbers
|
||||
? sessionTabShortcutLabel(tabNumber() - 1)
|
||||
: ""}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box width={numberWidth()}>
|
||||
<Spinner color={numberColor()} />
|
||||
</box>
|
||||
</Show>
|
||||
<text
|
||||
width={availableTitleWidth()}
|
||||
fg={foreground()}
|
||||
|
||||
@@ -153,6 +153,9 @@ export const Info = Schema.Struct({
|
||||
layout: Schema.optional(Schema.Literals(["horizontal", "vertical"])).annotate({
|
||||
description: "Show tabs in a horizontal strip or vertical sidebar",
|
||||
}),
|
||||
numbers: Schema.optional(Schema.Boolean).annotate({
|
||||
description: "Show numeric shortcuts beside session tabs",
|
||||
}),
|
||||
}),
|
||||
).annotate({ description: "Tab strip settings" }),
|
||||
mini: Schema.optional(
|
||||
@@ -224,6 +227,7 @@ export type Resolved = Omit<Info, "attention" | "cursor" | "keybinds" | "leader"
|
||||
enabled: boolean
|
||||
scope: "global" | "cwd"
|
||||
layout: "horizontal" | "vertical"
|
||||
numbers: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,6 +273,7 @@ export function resolve(input: Info, options: { terminalSuspend: boolean }): Res
|
||||
enabled: input.tabs?.enabled ?? true,
|
||||
scope: input.tabs?.scope ?? "cwd",
|
||||
layout: input.tabs?.layout ?? "horizontal",
|
||||
numbers: input.tabs?.numbers ?? false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,12 +22,12 @@ test("releasing a transcript selection over tab controls does not activate them"
|
||||
close() {},
|
||||
move() {},
|
||||
add: () => setAdded((value) => value + 1),
|
||||
status: () => EMPTY_SESSION_TAB_STATUS,
|
||||
status: () => ({ ...EMPTY_SESSION_TAB_STATUS, busy: true }),
|
||||
} satisfies SessionTabsController
|
||||
const app = await testRender(
|
||||
() => (
|
||||
<TestTuiContexts>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
|
||||
<ConfigProvider config={createTuiResolvedConfig({ animations: false, tabs: { enabled: true } })}>
|
||||
<ThemeProvider mode="dark" source={emptyThemeSource}>
|
||||
<box flexDirection="column">
|
||||
<SessionTabs controller={controller} animations={false} />
|
||||
@@ -43,6 +43,8 @@ test("releasing a transcript selection over tab controls does not activate them"
|
||||
try {
|
||||
app.renderer.start()
|
||||
await app.waitForFrame((frame) => frame.includes("Second"))
|
||||
expect(app.captureCharFrame()).not.toContain("1 First")
|
||||
expect(app.captureCharFrame()).toContain("⋯ First")
|
||||
await app.mockMouse.pressDown(5, 1)
|
||||
await app.mockMouse.release(40, 0)
|
||||
expect(active()).toBe("first")
|
||||
|
||||
@@ -20,11 +20,12 @@ test("validates mini replay settings", () => {
|
||||
test("validates the session tabs setting", () => {
|
||||
const decode = Schema.decodeUnknownSync(Info)
|
||||
|
||||
expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
|
||||
tabs: { enabled: true, layout: "vertical" },
|
||||
expect(decode({ tabs: { enabled: true, layout: "vertical", numbers: true } })).toEqual({
|
||||
tabs: { enabled: true, layout: "vertical", numbers: true },
|
||||
})
|
||||
expect(() => decode({ tabs: { layout: true } })).toThrow()
|
||||
expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
|
||||
expect(() => decode({ tabs: { numbers: "on" } })).toThrow()
|
||||
expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
|
||||
expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
|
||||
expect(decode({ session: { new_location: "inherit" } })).toEqual({ session: { new_location: "inherit" } })
|
||||
@@ -48,7 +49,7 @@ test("resolves nested config and keybind defaults", () => {
|
||||
expect(config.scroll).toEqual({ speed: 2, acceleration: true })
|
||||
expect(config.diffs).toEqual({ view: "split" })
|
||||
expect(config.debug).toEqual({ devtools: true })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
|
||||
expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal", numbers: false })
|
||||
expect(config.session.new_location).toBe("launch")
|
||||
})
|
||||
|
||||
@@ -56,6 +57,7 @@ test("shows resolved tab defaults in settings", () => {
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
|
||||
expect(settings.find((setting) => setting.path.join(".") === "tabs.numbers")?.default).toBe(false)
|
||||
})
|
||||
|
||||
test("shows the new session location default in settings", () => {
|
||||
|
||||
@@ -68,22 +68,6 @@ await $`bun ./packages/core/script/publish.ts`
|
||||
console.log("\n=== ui ===\n")
|
||||
await $`bun ./packages/ui/script/publish.ts`
|
||||
|
||||
if (Script.channel === "beta") {
|
||||
const packages = [
|
||||
"@opencode-ai/schema",
|
||||
"@opencode-ai/codemode",
|
||||
"@opencode-ai/theme",
|
||||
"@opencode-ai/ai",
|
||||
"@opencode-ai/util",
|
||||
"@opencode-ai/protocol",
|
||||
"@opencode-ai/client",
|
||||
"@opencode-ai/plugin",
|
||||
"@opencode-ai/core",
|
||||
"@opencode-ai/ui",
|
||||
]
|
||||
await Promise.all(packages.map((name) => $`npm dist-tag add ${`${name}@${Script.version}`} next`))
|
||||
}
|
||||
|
||||
if (Script.release) {
|
||||
await $`bun ./packages/desktop/scripts/finalize-latest-json.ts`
|
||||
await $`bun ./packages/desktop/scripts/finalize-latest-yml.ts`
|
||||
|
||||
Reference in New Issue
Block a user