Compare commits

..

1 Commits

Author SHA1 Message Date
Luke Parker 3a22a0251e fix(app): use custom composer scrollbar 2026-07-31 10:11:43 +00:00
180 changed files with 2614 additions and 3739 deletions
+4 -2
View File
@@ -67,7 +67,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -88,6 +87,7 @@
"@tanstack/solid-query": "5.91.4",
"@tanstack/solid-virtual": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
@@ -383,6 +383,7 @@
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -403,6 +404,7 @@
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
@@ -424,6 +426,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"drizzle-kit": "catalog:",
@@ -517,7 +520,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/diffs": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
+1 -2
View File
@@ -38,7 +38,7 @@ const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
// Protocols whose wire format ignores inline cache markers (OpenAI's implicit
// prefix caching, Gemini's implicit + out-of-band CachedContent). Skip the
// whole policy pass for these — emitting hints would be harmless but pointless.
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse", "openrouter"])
const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"])
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
@@ -133,7 +133,6 @@ const countHints = (request: LLMRequest) =>
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
if (request.model.route.id === "openrouter" && (request.cache === undefined || request.cache === "auto")) return request
const policy = resolve(request.cache)
if (!policy.tools && !policy.system && !policy.messages) return request
+26 -125
View File
@@ -11,7 +11,6 @@ import {
Usage,
type FinishReason,
type FinishReasonDetails,
type CacheHint,
type JsonSchema,
type LLMRequest,
type MediaPart,
@@ -39,11 +38,6 @@ export const PATH = "/chat/completions"
// The body schema is the provider-native JSON body. `fromRequest` below builds
// this shape from the common `LLMRequest`, then `Route.make` validates and
// JSON-encodes it before transport.
const OpenAIChatCacheControl = Schema.Struct({
type: Schema.Literal("ephemeral"),
ttl: Schema.optional(Schema.String),
})
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
description: Schema.String,
@@ -53,7 +47,6 @@ const OpenAIChatFunction = Schema.Struct({
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: OpenAIChatFunction,
cache_control: Schema.optional(OpenAIChatCacheControl),
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
@@ -68,11 +61,7 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
text: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({
type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }),
@@ -80,10 +69,7 @@ const OpenAIChatUserContent = Schema.Union([
])
const OpenAIChatMessage = Schema.Union([
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
}),
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
@@ -97,16 +83,10 @@ const OpenAIChatMessage = Schema.Union([
reasoning: Schema.optional(Schema.String),
reasoning_text: Schema.optional(Schema.String),
reasoning_details: Schema.optional(Schema.Unknown),
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
),
Schema.Struct({
role: Schema.Literal("tool"),
tool_call_id: Schema.String,
content: Schema.String,
cache_control: Schema.optional(OpenAIChatCacheControl),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
@@ -127,7 +107,6 @@ export const bodyFields = {
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_completion_tokens: Schema.optional(Schema.Number),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
@@ -230,20 +209,13 @@ export interface ParserState {
// Lowering is the only place that knows how common LLM messages map onto the
// OpenAI Chat wire format. Keep provider quirks here instead of leaking native
// fields into `LLMRequest`.
interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
}
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema, options: LoweringOptions): OpenAIChatTool => ({
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIChatTool => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: ToolSchemaProjection.openAI(inputSchema),
},
cache_control: options.cacheControl?.(tool.cache),
})
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
@@ -285,14 +257,11 @@ const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown)
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (part.type === "text") {
content.push({ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) })
content.push({ type: "text", text: part.text })
continue
}
if (part.type === "media") {
@@ -301,18 +270,14 @@ const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (
}
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "user", ["text", "media"])
}
if (content.every((part) => part.type === "text" && part.cache_control === undefined))
return {
role: "user" as const,
content: content.map((part) => (part.type === "text" ? part.text : "")).join(""),
}
if (content.every((part) => part.type === "text"))
return { role: "user" as const, content: content.map((part) => part.text).join("") }
return { role: "user" as const, content }
})
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
configuredField?: string,
options: LoweringOptions = {},
) {
const content: TextPart[] = []
const reasoning: ReasoningPart[] = []
@@ -350,44 +315,29 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
if (reasoning.length === 0) return nativeReasoning
return text
})()
const cached = message.content.findLast((part) => "cache" in part && part.cache !== undefined)
const result = {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_details: details,
cache_control: options.cacheControl?.(cached && "cache" in cached ? cached.cache : undefined),
}
if (field === undefined || reasoningText === undefined) return result
return { ...result, [field]: reasoningText }
})
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
message: OpenAIChatRequestMessage,
options: LoweringOptions,
) {
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
const messages: OpenAIChatMessage[] = []
const images: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
for (const part of message.content) {
if (!ProviderShared.supportsContent(part, ["tool-result"]))
return yield* ProviderShared.unsupportedContent("OpenAI Chat", "tool", ["tool-result"])
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
continue
}
const content: ReadonlyArray<Tool.Content> = part.result.value
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
const files = content.filter((item) => item.type === "file")
images.push(
...(yield* Effect.forEach(files, (item) =>
@@ -401,29 +351,15 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (
message: OpenAIChatRequestMessage,
reasoningField?: string,
options: LoweringOptions = {},
) {
if (message.role === "user") return [yield* lowerUserMessage(message, options)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField, options)]
return (yield* lowerToolMessages(message, options)).messages
if (message.role === "user") return [yield* lowerUserMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message, reasoningField)]
return (yield* lowerToolMessages(message)).messages
})
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest, options: LoweringOptions) {
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIChatMessage[] =
request.system.length === 0
? []
: request.system.some((part) => part.cache !== undefined) && options.cacheControl !== undefined
? [
{
role: "system",
content: request.system.map((part) => ({
type: "text",
text: part.text,
cache_control: options.cacheControl?.(part.cache),
})),
},
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
@@ -434,53 +370,28 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
messages.push({
role: "user",
content: [
...pendingImages.splice(0),
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
})
messages.push({ role: "user", content: [...pendingImages.splice(0), { type: "text", text: part.text }] })
continue
}
const previous = messages.at(-1)
if (previous?.role === "user" && typeof previous.content === "string")
messages[messages.length - 1] = options.cacheControl?.(part.cache)
? {
role: "user",
content: [
{ type: "text", text: previous.content },
{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) },
],
}
: { role: "user", content: `${previous.content}\n${part.text}` }
messages[messages.length - 1] = { role: "user", content: `${previous.content}\n${part.text}` }
else if (previous?.role === "user" && Array.isArray(previous.content))
messages[messages.length - 1] = {
role: "user",
content: [
...previous.content,
{ type: "text", text: part.text, cache_control: options.cacheControl?.(part.cache) },
],
content: [...previous.content, { type: "text", text: part.text }],
}
else
messages.push(
options.cacheControl?.(part.cache)
? {
role: "user",
content: [{ type: "text", text: part.text, cache_control: options.cacheControl(part.cache) }],
}
: { role: "user", content: part.text },
)
else messages.push({ role: "user", content: part.text })
continue
}
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, options)
const lowered = yield* lowerToolMessages(message)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField)))
}
flushImages()
return messages
@@ -494,10 +405,7 @@ const lowerOptions = (request: LLMRequest) => {
}
}
export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
request: LLMRequest,
options: LoweringOptions = {},
) {
const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (request: LLMRequest) {
// `fromRequest` returns the provider body only. Endpoint, auth, framing,
// validation, and HTTP execution are composed by `Route.make`.
const reasoningField = request.model.compatibility?.reasoningField
@@ -507,26 +415,19 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
const maxTokensField = request.model.compatibility?.maxTokensField ?? "max_tokens"
return {
model: request.model.id,
messages: yield* lowerMessages(request, options),
messages: yield* lowerMessages(request),
tools:
request.tools.length === 0
? undefined
: request.tools.map((tool) =>
lowerTool(
tool,
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
options,
),
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
),
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
stream: true as const,
stream_options: { include_usage: true },
...(maxTokensField === "max_completion_tokens"
? { max_completion_tokens: generation?.maxTokens }
: { max_tokens: generation?.maxTokens }),
max_tokens: generation?.maxTokens,
temperature: generation?.temperature,
top_p: generation?.topP,
frequency_penalty: generation?.frequencyPenalty,
+9 -8
View File
@@ -16,6 +16,7 @@ import {
const patterns = [
/prompt is too long/i,
/request_too_large/i,
/input is too long for requested model/i,
/exceeds the context window/i,
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
@@ -32,6 +33,7 @@ const patterns = [
/context window exceeds limit/i,
/exceeded model token limit/i,
/context[_ ]length[_ ]exceeded/i,
/request entity too large/i,
/context length is only \d+ tokens/i,
/input length.*exceeds.*context length/i,
/prompt too long; exceeded (?:max )?context length/i,
@@ -42,15 +44,11 @@ const patterns = [
/token limit exceeded/i,
]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isContextOverflowFailure = (failure: unknown) =>
failure instanceof LLMError
@@ -102,8 +100,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
isContextOverflow(text))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
if (input.status === 413 || isPayloadTooLarge(text))
return new InvalidRequestReason({ ...common, classification: "payload-too-large" })
if (CONTENT_POLICY_TEXT.test(text)) return new ContentPolicyReason(common)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
return new QuotaExceededReason(common)
@@ -146,7 +142,12 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
retryAfterMs: input.retryAfterMs,
})
if (codes.some((code) => INVALID_REQUEST_CODES.has(code))) return new InvalidRequestReason(common)
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
if (
input.status === 400 ||
input.status === 404 ||
input.status === 413 ||
input.status === 422
)
return new InvalidRequestReason(common)
return new UnknownProviderReason({ ...common, status: input.status })
}
-1
View File
@@ -6,7 +6,6 @@ export interface Settings extends Readonly<Record<string, unknown>> {
readonly body?: Readonly<Record<string, unknown>>
readonly limits?: {
readonly context: number
readonly input?: number
readonly output: number
}
}
+10 -84
View File
@@ -4,72 +4,21 @@ import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type CacheHint, type ModelID, type ProviderOptions } from "../schema"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import type { ProviderPackage } from "../provider-package"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache"
import { isRecord } from "../protocols/shared"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
const ADAPTER = "openrouter"
type OpenRouterString<Known extends string> = Known | (string & {})
export interface OpenRouterProviderRouting {
readonly [key: string]: unknown
readonly order?: ReadonlyArray<string>
readonly allow_fallbacks?: boolean
readonly require_parameters?: boolean
readonly data_collection?: OpenRouterString<"allow" | "deny">
readonly only?: ReadonlyArray<string>
readonly ignore?: ReadonlyArray<string>
readonly quantizations?: ReadonlyArray<string>
readonly sort?: OpenRouterString<"price" | "throughput" | "latency">
readonly max_price?: Readonly<{
prompt?: number | string
completion?: number | string
image?: number | string
audio?: number | string
request?: number | string
}>
readonly zdr?: boolean
}
export type OpenRouterPlugin =
| Readonly<{
id: "web"
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
| Readonly<{ id: "file-parser"; max_files?: number; pdf?: { engine?: string } }>
| Readonly<{ id: "moderation" }>
| Readonly<{ id: "response-healing" }>
| Readonly<{ id: "auto-router"; allowed_models?: ReadonlyArray<string> }>
| Readonly<{ id: string & {}; [key: string]: unknown }>
export interface OpenRouterOptions {
readonly [key: string]: unknown
readonly debug?: Readonly<{ echo_upstream_body?: boolean }>
readonly models?: ReadonlyArray<string>
readonly plugins?: ReadonlyArray<OpenRouterPlugin>
readonly usage?: boolean | Record<string, unknown>
readonly reasoning?: Record<string, unknown>
readonly promptCacheKey?: string
readonly provider?: OpenRouterProviderRouting
readonly reasoning?: Readonly<{
enabled?: boolean
exclude?: boolean
effort?: OpenRouterString<"none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max">
max_tokens?: number
}>
readonly usage?: boolean | Readonly<{ include: boolean }>
readonly user?: string
readonly web_search_options?: Readonly<{
max_results?: number
search_prompt?: string
engine?: OpenRouterString<"native" | "exa">
}>
}
export type OpenRouterProviderOptionsInput = ProviderOptions & {
@@ -98,7 +47,7 @@ export const protocol = Protocol.make({
body: {
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.fromRequest(request, { cacheControl: cacheControl() }).pipe(
OpenAIChat.protocol.body.from(request).pipe(
Effect.map((body) => {
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
let assistantIndex = 0
@@ -129,39 +78,16 @@ export const protocol = Protocol.make({
stream: OpenAIChat.protocol.stream,
})
const cacheControl = () => {
const breakpoints = newBreakpoints(4)
return (cache: CacheHint | undefined) => {
if (cache === undefined || breakpoints.remaining === 0) return undefined
breakpoints.remaining -= 1
return {
type: "ephemeral" as const,
...(ttlBucket(cache.ttlSeconds) === "1h" ? { ttl: "1h" } : {}),
}
}
}
const bodyOptions = (input: unknown) => {
const openrouter = isRecord(input) ? input : {}
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
openrouter
return {
...options,
...(usage === undefined || usage === true
...(openrouter.usage === true
? { usage: { include: true } }
: usage === false
? { usage: { include: false } }
: isRecord(usage)
? { usage }
: {}),
...(Array.isArray(models) ? { models } : {}),
...(isRecord(provider) ? { provider } : {}),
...(Array.isArray(plugins) ? { plugins } : {}),
...(isRecord(web_search_options) ? { web_search_options } : {}),
...(isRecord(debug) ? { debug } : {}),
...(typeof user === "string" ? { user } : {}),
...(isRecord(reasoning) ? { reasoning } : {}),
...(typeof promptCacheKey === "string" ? { prompt_cache_key: promptCacheKey } : {}),
: isRecord(openrouter.usage)
? { usage: openrouter.usage }
: {}),
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
}
}
+4 -7
View File
@@ -5,7 +5,7 @@ import { Endpoint, type EndpointPatch } from "./endpoint"
import { RequestExecutor } from "./executor"
import { Framing } from "./framing"
import { HttpTransport } from "./transport"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport"
import type { HttpRequestTransform, Transport, TransportRuntime } from "./transport"
import { WebSocketExecutor } from "./transport"
import type { Protocol } from "./protocol"
import { applyCachePolicy } from "../cache-policy"
@@ -96,10 +96,7 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
type RouteMappedModelInput = RouteModelInput | RouteRoutedModelInput
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(
route: AnyRoute,
mapped: RouteMappedModelInput,
) => {
const makeRouteModel = <Options extends ProviderOptions = ProviderOptions>(route: AnyRoute, mapped: RouteMappedModelInput) => {
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
if (!endpointBaseURL(route.endpoint))
@@ -153,7 +150,7 @@ export interface Interface {
}
export interface StreamOptions {
readonly http?: HttpMiddleware
readonly transform?: HttpRequestTransform
}
export interface StreamMethod {
@@ -305,7 +302,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
auth: routeInput.auth ?? Auth.none,
encodeBody,
headers: routeInput.headers,
middleware: options?.http,
transform: options?.transform,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
const route = `${request.model.provider}/${request.model.route.id}`
+1 -1
View File
@@ -23,4 +23,4 @@ export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-option
export type { Definition as EndpointFn, EndpointInput } from "./endpoint"
export type { Definition as FramingDef } from "./framing"
export type { Protocol as ProtocolDef } from "./protocol"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport"
export type { HttpRequest, HttpRequestTransform, Transport as TransportDef, TransportRuntime } from "./transport"
+60 -55
View File
@@ -1,11 +1,11 @@
import { Effect, Stream } from "effect"
import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import { Auth } from "../auth"
import { render as renderEndpoint } from "../endpoint"
import { Framing } from "../framing"
import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index"
import type { Transport, TransportPrepareInput } from "./index"
import * as ProviderShared from "../../protocols/shared"
import { LLMError, mergeJsonRecords, type LLMRequest } from "../../schema"
import { mergeJsonRecords, type LLMRequest } from "../../schema"
export type JsonRequestInput<Body> = TransportPrepareInput<Body>
@@ -18,9 +18,7 @@ export interface JsonRequestParts<Body = unknown> {
export interface HttpPrepared<Frame> {
readonly request: HttpClientRequest.HttpClientRequest
readonly web: Request
readonly framing: Framing.Definition<Frame>
readonly middleware?: HttpMiddleware
}
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
@@ -30,9 +28,57 @@ const applyQuery = (url: string, query: Record<string, string> | undefined) => {
return next.toString()
}
const PROTOCOL_BODY_OVERLAY_DENYLIST = new Set([
"anthropic_version",
"content",
"contents",
"frequencyPenalty",
"frequency_penalty",
"generationConfig",
"inferenceConfig",
"input",
"maxTokens",
"max_tokens",
"messages",
"model",
"presencePenalty",
"presence_penalty",
"responseFormat",
"response_format",
"seed",
"stop",
"stopSequences",
"stop_sequences",
"stream",
"streamOptions",
"stream_options",
"system",
"systemInstruction",
"system_instruction",
"temperature",
"thinking",
"toolChoice",
"toolConfig",
"tool_choice",
"tool_config",
"tools",
"topK",
"topP",
"top_k",
"top_p",
])
const forbiddenBodyOverlayKeys = (body: Record<string, unknown>) =>
Object.keys(body).filter((key) => PROTOCOL_BODY_OVERLAY_DENYLIST.has(key))
const bodyWithOverlay = <Body>(body: Body, request: LLMRequest, encodeBody: (body: Body) => string) =>
Effect.gen(function* () {
if (request.http?.body === undefined) return { jsonBody: body, bodyText: encodeBody(body) }
const forbiddenKeys = forbiddenBodyOverlayKeys(request.http.body)
if (forbiddenKeys.length > 0)
return yield* ProviderShared.invalidRequest(
`http.body cannot overlay protocol-owned field(s): ${forbiddenKeys.join(", ")}`,
)
if (ProviderShared.isRecord(body)) {
const overlaid = mergeJsonRecords(body, request.http.body) ?? {}
return { jsonBody: overlaid, bodyText: ProviderShared.encodeJson(overlaid) }
@@ -76,62 +122,21 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
prepare: (prepareInput) =>
Effect.gen(function* () {
const parts = yield* jsonRequestParts({ ...prepareInput })
const request = ProviderShared.jsonPost({
url: parts.url,
body: parts.bodyText,
headers: parts.headers,
})
const request = { url: parts.url, method: "POST", headers: { ...parts.headers }, body: parts.bodyText }
yield* (prepareInput.transform?.(request) ?? Effect.void)
return {
request,
web: new Request(parts.url, { method: "POST", headers: parts.headers, body: parts.bodyText }),
request: ProviderShared.jsonPost({
url: request.url,
body: request.body ?? "",
headers: Headers.fromInput(request.headers),
}),
framing: input.framing,
middleware: prepareInput.middleware,
}
}),
frames: (prepared, request, runtime) =>
Stream.unwrap(
Effect.gen(function* () {
const request = prepared.web
const execute = (input: Request) =>
Effect.tryPromise({
try: () => input.text(),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
}).pipe(
Effect.flatMap((body) =>
runtime.http.execute(
ProviderShared.jsonPost({
url: input.url,
body,
headers: Headers.fromInput(input.headers),
}),
),
),
Effect.flatMap((response) =>
Stream.toReadableStreamEffect(response.stream).pipe(
Effect.map(
(body) =>
new Response(body, {
status: response.status,
headers: response.headers,
}),
),
),
),
)
return yield* prepared.middleware ? prepared.middleware(request, execute) : execute(request)
})
.pipe(
Effect.mapError((error) =>
error instanceof LLMError
? error
: ProviderShared.eventError(
`${request.model.provider}/${request.model.route.id}`,
`Failed to execute ${request.model.provider}/${request.model.route.id} request`,
ProviderShared.errorText(error),
),
),
Effect.map((response) => HttpClientResponse.fromWeb(prepared.request, response)),
)
runtime.http
.execute(prepared.request)
.pipe(
Effect.map((response) =>
prepared.framing.frame(
+9 -3
View File
@@ -10,8 +10,14 @@ export interface TransportRuntime {
readonly webSocket?: WebSocketExecutorInterface
}
export type HttpHandler = (request: Request) => Effect.Effect<Response, Error>
export type HttpMiddleware = (request: Request, handler: HttpHandler) => Effect.Effect<Response, Error>
export interface HttpRequest {
url: string
readonly method: string
headers: Record<string, string>
body: string | undefined
}
export type HttpRequestTransform = (request: HttpRequest) => Effect.Effect<void>
export interface Transport<Body, Prepared, Frame> {
readonly id: string
@@ -30,7 +36,7 @@ export interface TransportPrepareInput<Body> {
readonly auth: Auth.Definition
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly middleware?: HttpMiddleware
readonly transform?: HttpRequestTransform
}
export * as HttpTransport from "./http"
+1 -1
View File
@@ -2,7 +2,7 @@ import { Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
export const ProviderFailureClassification = Schema.Literals(["context-overflow", "payload-too-large"])
export const ProviderFailureClassification = Schema.Literal("context-overflow")
export type ProviderFailureClassification = typeof ProviderFailureClassification.Type
export class HttpRequestDetails extends Schema.Class<HttpRequestDetails>("LLM.HttpRequestDetails")({
-2
View File
@@ -124,7 +124,6 @@ export const ToolCallPart = Object.assign(
name: Schema.String,
input: Schema.Unknown,
providerExecuted: Schema.optional(Schema.Boolean),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.ToolCall" }),
@@ -169,7 +168,6 @@ export const ReasoningPart = Schema.Struct({
type: Schema.Literal("reasoning"),
text: Schema.String,
encrypted: Schema.optional(Schema.String),
cache: Schema.optional(CacheHint),
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
providerMetadata: Schema.optional(ProviderMetadata),
}).annotate({ identifier: "LLM.Content.Reasoning" })
-5
View File
@@ -123,7 +123,6 @@ export const mergeGenerationOptions = (...items: ReadonlyArray<GenerationOptions
export class ModelLimits extends Schema.Class<ModelLimits>("LLM.ModelLimits")({
context: Schema.optional(Schema.Number),
input: Schema.optional(Schema.Number),
output: Schema.optional(Schema.Number),
}) {}
@@ -167,13 +166,9 @@ export namespace ModelDefaults {
export const ModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type ModelToolSchemaCompatibility = Schema.Schema.Type<typeof ModelToolSchemaCompatibility>
export const ModelMaxTokensFieldCompatibility = Schema.Literals(["max_completion_tokens", "max_tokens"])
export type ModelMaxTokensFieldCompatibility = Schema.Schema.Type<typeof ModelMaxTokensFieldCompatibility>
export class ModelCompatibility extends Schema.Class<ModelCompatibility>("LLM.ModelCompatibility")({
toolSchema: Schema.optional(ModelToolSchemaCompatibility),
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(ModelMaxTokensFieldCompatibility),
}) {}
export namespace ModelCompatibility {
+18 -63
View File
@@ -146,18 +146,12 @@ describe("request option precedence", () => {
prompt: "Say hello.",
}),
{
http: (request, handler) =>
Effect.gen(function* () {
expect(request.headers.get("authorization")).toBe("Bearer fresh-key")
const headers = new Headers(request.headers)
headers.set("x-plugin", "transformed")
return yield* handler(
new Request("https://proxy.test/v1/chat/completions", {
method: request.method,
headers,
body: JSON.stringify({ transformed: true }),
}),
)
transform: (request) =>
Effect.sync(() => {
expect(request.headers.authorization).toBe("Bearer fresh-key")
request.url = "https://proxy.test/v1/chat/completions"
request.headers["x-plugin"] = "transformed"
request.body = JSON.stringify({ transformed: true })
}),
},
).pipe(
@@ -177,65 +171,26 @@ describe("request option precedence", () => {
),
)
it.effect("transforms the HTTP response before protocol decoding", () =>
it.effect("rejects raw body overlays for protocol-owned roots", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
const model = OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" })
const error = yield* compileRequest(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
model,
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
{
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(request)
const body = yield* Effect.promise(() => response.text())
return new Response(body.replace("network", "hooked"), {
status: response.status,
headers: response.headers,
})
}),
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.succeed(
input.respond(sseEvents(deltaChunk({ content: "network" }, "stop")), {
headers: { "content-type": "text/event-stream" },
}),
),
),
),
)
).pipe(Effect.flip)
expect(response.text).toBe("hooked")
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "http.body cannot overlay protocol-owned field(s): model, messages, tools",
})
}),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "Say hello.",
http: { body: { model: "gpt-5", messages: [], tools: [] } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({ model: "gpt-5", messages: [], tools: [] })
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("uses model output limits after route limits and before call maxTokens", () =>
Effect.gen(function* () {
const route = AnthropicMessages.route.with({
+3 -6
View File
@@ -85,17 +85,14 @@ describe("RequestExecutor", () => {
),
)
it.effect("classifies generic HTTP 413 payload errors", () =>
it.effect("does not classify generic HTTP 413 payload errors as context overflow", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectLLMError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "payload-too-large",
http: { response: { status: 413 } },
})
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
+4 -22
View File
@@ -6,6 +6,7 @@ describe("provider error classification", () => {
test("classifies provider token limit messages as context overflow", () => {
const messages = [
"tokens in request more than max tokens allowed",
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
"Input length (265330) exceeds model's maximum context length (262144).",
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
@@ -18,24 +19,6 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true)
})
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
expect(failures).toEqual(
failures.map((failure) =>
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => {
const messages = [
"Throttling error: Too many tokens, please wait before trying again.",
@@ -76,10 +59,9 @@ describe("provider error classification", () => {
})
test("classifies transient client statuses as provider internal", () => {
expect([408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag)).toEqual([
"ProviderInternal",
"ProviderInternal",
])
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -5,28 +5,6 @@ const model = OpenRouter.provider.model("anthropic/claude-sonnet-4.5")
LLM.request({ model, prompt: "Hello", providerOptions: { openrouter: { usage: true } } })
LLM.request({
model,
prompt: "Hello",
providerOptions: {
openrouter: {
models: ["google/gemini-3.1-pro"],
provider: {
order: ["anthropic"],
require_parameters: true,
data_collection: "future-policy",
sort: "future-sort",
max_price: { prompt: "0.50" },
},
reasoning: { effort: "future-effort", exclude: false },
plugins: [{ id: "future-plugin", enabled: true }],
web_search_options: { engine: "future-engine" },
debug: { echo_upstream_body: true },
user: "user_123",
},
},
})
LLM.request({
model,
prompt: "Hello",
@@ -181,6 +181,23 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("protects the Vertex Messages API version from body overlays", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({
accessToken: "vertex-token",
http: { body: { anthropic_version: "wrong" } },
project: "vertex-project",
}).model("claude-sonnet-4-6"),
prompt: "Say hello.",
}),
).pipe(Effect.flip)
expect(error.message).toContain("http.body cannot overlay protocol-owned field(s): anthropic_version")
}),
)
it.effect("routes tuned Gemini models through their deployed endpoint", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -144,20 +144,6 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("configures the max tokens request field", () =>
Effect.gen(function* () {
const compatible = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: "custom-model", compatibility: { maxTokensField: "max_completion_tokens" } })
const prepared = yield* compileRequest(
LLM.request({ model: compatible, prompt: "Say hello.", generation: { maxTokens: 20 } }),
)
expect(prepared.body).toMatchObject({ max_completion_tokens: 20 })
expect(prepared.body).not.toHaveProperty("max_tokens")
}),
)
it.effect("matches AI SDK compatible tool request body fixture", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
+1 -177
View File
@@ -1,6 +1,6 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { CacheHint, LLM, Message } from "../../src"
import { LLM, Message } from "../../src"
import { LLMClient } from "../../src/route"
import { compileRequest } from "../../src/route/client"
import * as OpenRouter from "../../src/providers/openrouter"
@@ -27,131 +27,10 @@ describe("OpenRouter", () => {
model: "openai/gpt-4o-mini",
messages: [{ role: "user", content: "Say hello." }],
stream: true,
usage: { include: true },
})
}),
)
it.effect("lowers the native cache policy to OpenRouter cache controls", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
system: [
{ type: "text", text: "Base agent", cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }) },
{ type: "text", text: "Project instructions" },
],
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
prompt: "Hello",
cache: { tools: true, system: true, messages: { tail: 1 } },
}),
)
expect(prepared.body).toMatchObject({
tools: [{ cache_control: { type: "ephemeral" } }],
messages: [
{
role: "system",
content: [
{ text: "Base agent", cache_control: { type: "ephemeral", ttl: "1h" } },
{ text: "Project instructions", cache_control: { type: "ephemeral" } },
],
},
{
role: "user",
content: [{ text: "Hello", cache_control: { type: "ephemeral" } }],
},
],
})
}),
)
it.effect("lowers manual assistant and tool-result cache hints", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.user("Call the tool"),
Message.assistant([
{ type: "text", text: "Calling", cache: new CacheHint({ type: "ephemeral" }) },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
]),
Message.tool({
id: "call_1",
name: "lookup",
result: "Done",
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3_600 }),
}),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Call the tool" },
{ role: "assistant", content: "Calling", cache_control: { type: "ephemeral" } },
{ role: "tool", content: '"Done"', cache_control: { type: "ephemeral", ttl: "1h" } },
])
}),
)
it.effect("caps manual cache controls at four breakpoints", () =>
Effect.gen(function* () {
const cache = new CacheHint({ type: "ephemeral" })
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
system: [1, 2, 3, 4, 5].map((index) => ({ type: "text" as const, text: `System ${index}`, cache })),
prompt: "Hello",
}),
)
const system = prepared.body.messages[0]
expect(system?.role).toBe("system")
expect(
system && Array.isArray(system.content)
? system.content.filter((part) => "cache_control" in part && part.cache_control !== undefined)
: [],
).toHaveLength(4)
}),
)
it.effect("preserves cache policy hints on reasoning-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: { messages: "latest-assistant" },
messages: [Message.user("Think"), Message.assistant([{ type: "reasoning", text: "Reasoning" }])],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "user", content: "Think" },
{ role: "assistant", cache_control: { type: "ephemeral" } },
])
}),
)
it.effect("allows usage accounting to be disabled explicitly", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: { usage: false } },
}).model("openai/gpt-4o-mini"),
cache: "none",
prompt: "Hello",
}),
)
expect(prepared.body.usage).toEqual({ include: false })
}),
)
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -163,13 +42,6 @@ describe("OpenRouter", () => {
usage: true,
reasoning: { effort: "high" },
promptCacheKey: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
},
},
}).model("anthropic/claude-3.7-sonnet:thinking"),
@@ -181,54 +53,10 @@ describe("OpenRouter", () => {
usage: { include: true },
reasoning: { effort: "high" },
prompt_cache_key: "session_123",
models: ["anthropic/claude-sonnet-4.6", "google/gemini-3.1-pro"],
provider: { order: ["anthropic", "google"], require_parameters: true },
plugins: [{ id: "response-healing" }],
web_search_options: { engine: "native", max_results: 3 },
debug: { echo_upstream_body: true },
user: "user_123",
future_option: { enabled: true },
})
}),
)
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
Effect.gen(function* () {
const invalid: Record<string, unknown> = {
usage: "yes",
models: "anthropic/claude-sonnet-4.6",
provider: [],
plugins: {},
web_search_options: [],
debug: [],
user: 123,
reasoning: [],
promptCacheKey: 123,
future_option: { enabled: true },
}
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({
apiKey: "test-key",
providerOptions: { openrouter: invalid },
}).model("openai/gpt-4o-mini"),
prompt: "Hello",
}),
)
expect(prepared.body).toMatchObject({ future_option: { enabled: true } })
expect(prepared.body).not.toHaveProperty("usage")
expect(prepared.body).not.toHaveProperty("models")
expect(prepared.body).not.toHaveProperty("provider")
expect(prepared.body).not.toHaveProperty("plugins")
expect(prepared.body).not.toHaveProperty("web_search_options")
expect(prepared.body).not.toHaveProperty("debug")
expect(prepared.body).not.toHaveProperty("user")
expect(prepared.body).not.toHaveProperty("reasoning")
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("preserves the upstream provider finish reason", () =>
Effect.gen(function* () {
const model = OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6")
@@ -276,7 +104,6 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant([
{
@@ -310,7 +137,6 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -336,7 +162,6 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [
Message.assistant({
type: "reasoning",
@@ -358,7 +183,6 @@ describe("OpenRouter", () => {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
cache: "none",
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
}),
)
-1
View File
@@ -59,7 +59,6 @@
"@opencode-ai/sdk": "file:vendor/opencode-ai-sdk-1.18.8-dev.tgz",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@pierre/trees": "1.0.0-beta.4",
"@sentry/solid": "catalog:",
"@shikijs/transformers": "3.9.2",
@@ -4,7 +4,6 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useGlobal } from "@/context/global"
import { ServerConnection, serverName } from "@/context/server"
import { displayName, projectForSession } from "@/pages/layout/helpers"
@@ -12,6 +11,7 @@ import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { Session } from "@opencode-ai/sdk/v2"
import { canOpenTabRename, forwardTabRef } from "./titlebar-tab-gesture"
import { TabPreviewPopover } from "./titlebar-tab-popover"
import { sessionTitle } from "@/utils/session-title"
import "./titlebar-tab-nav.css"
// MouseEvent.button uses 1 for the middle/wheel button.
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
})
const title = createMemo(() => {
const session = props.session()
return session ? displayLabel(session) : props.fallbackTitle
return session ? sessionTitle(session.title, session.parentID) : props.fallbackTitle
})
const projectName = createMemo(() => {
@@ -3,11 +3,11 @@ import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server"
import { displayName } from "@/pages/layout/helpers"
import { makeEventListener } from "@solid-primitives/event-listener"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller"
import { sessionTitle } from "@/utils/session-title"
type HomeSessionSearchSource = Pick<HomeSessionsController, "data" | "session">
@@ -24,7 +24,7 @@ export function createHomeSessionSearchController(home: HomeController, sessions
if (!value) return []
return sessions.data
.searchRecords()
.filter((record) => `${displayLabel(record.session)} ${record.projectName}`.toLowerCase().includes(value))
.filter((record) => `${sessionTitle(record.session.title)} ${record.projectName}`.toLowerCase().includes(value))
})
const active = createMemo(() => {
const records = results()
@@ -6,10 +6,10 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionTitle } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
import {
HomeSessionStatusController,
@@ -344,7 +344,7 @@ function HomeSessionSearchResultRow(
selected: boolean
},
) {
const title = createMemo(() => displayLabel(props.record.session))
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
const key = () => homeSessionSearchKey(props.record)
@@ -415,7 +415,7 @@ function HomeSessionGroupHeader(props: {
}
function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) {
const title = createMemo(() => displayLabel(props.record.session))
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const showProjectName = () => props.showProjectName() && props.record.projectName
return (
@@ -5,7 +5,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { getFilename } from "@opencode-ai/core/util/path"
import { A, useParams } from "@solidjs/router"
import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
@@ -15,6 +14,7 @@ import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
import { childSessionOnPath, getProjectAvatarSource, hasProjectPermissions } from "./helpers"
@@ -104,7 +104,7 @@ const SessionRow = (props: {
warmPress: () => void
warmFocus: () => void
}): JSX.Element => {
const title = () => displayLabel(props.session)
const title = () => sessionTitle(props.session.title, props.session.parentID)
return (
<A
@@ -229,7 +229,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
fallback={
<Tooltip
placement={props.mobile ? "bottom" : "right"}
value={displayLabel(props.session)}
value={sessionTitle(props.session.title, props.session.parentID)}
gutter={10}
class="min-w-0 w-full"
>
@@ -70,7 +70,7 @@ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/sessio
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import { displayLabel } from "@opencode-ai/util/session-title-fallback"
import { sessionTitle } from "@/utils/session-title"
import { scheduleConnectedMeasure } from "./measure"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { createTimelineProjection } from "./projection"
@@ -296,10 +296,11 @@ export function MessageTimeline(props: {
if (!id) return
return sync().session.get(id)
})
const titleValue = createMemo(() => info()?.title)
const titleLabel = createMemo(() => {
const session = info()
if (!session) return
return displayLabel(session)
return sessionTitle(titleValue(), session.parentID)
})
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync().data.config.share !== "disabled")
@@ -316,7 +317,7 @@ export function MessageTimeline(props: {
})
const parentTitle = createMemo(() => {
const session = parent()
return session ? displayLabel(session) : language.t("command.session.new")
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
})
const getMsgParts = (msgId: string) => sync().data.part[msgId] ?? emptyParts
const getMsgPart = (messageID: string, partID: string) => getMsgParts(messageID).find((part) => part.id === partID)
@@ -920,7 +921,7 @@ export function MessageTimeline(props: {
function DialogDeleteSession(props: { sessionID: string }) {
const name = createMemo(() => {
const session = sync().session.get(props.sessionID)
return session ? displayLabel(session) : language.t("command.session.new")
return session ? sessionTitle(session.title, session.parentID) : language.t("command.session.new")
})
const handleDelete = async () => {
await deleteSession(props.sessionID)
@@ -38,19 +38,17 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
instance.scrollOffset = offset
})
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([])
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
await frames(3)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 79_400
document.body.append(unrelated)
unrelated.remove()
await frames(2)
expect(calls).toEqual([[0, false]])
instance.scrollOffset = 0
route.remove()
document.body.append(route)
await new Promise((resolve) => setTimeout(resolve, 0))
@@ -0,0 +1,11 @@
import { describe, expect, test } from "bun:test"
import { sessionTitle } from "./session-title"
describe("sessionTitle", () => {
test("uses a display fallback without persisting it", () => {
expect(sessionTitle(undefined)).toBe("New session")
expect(sessionTitle(undefined, "ses_parent")).toBe("Child session")
expect(sessionTitle("New session - 2026-07-30T18:45:03.662Z")).toBe("New session")
expect(sessionTitle("Generated title")).toBe("Generated title")
})
})
+7
View File
@@ -0,0 +1,7 @@
const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
export function sessionTitle(title?: string, parentID?: string) {
if (!title) return parentID ? "Child session" : "New session"
const match = title.match(pattern)
return match?.[1] ?? title
}
+1 -2
View File
@@ -1,6 +1,5 @@
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
import type { Session } from "@opencode-ai/sdk/v2/client"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
export function normalizeSessionInfo(input: SessionInfo | Session): Session {
if (!("location" in input)) return input
@@ -14,7 +13,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session {
parentID: input.parentID,
cost: input.cost,
tokens: input.tokens,
title: withTimestampedFallback(input),
title: input.title ?? `${input.parentID ? "Child" : "New"} session - ${new Date(input.time.created).toISOString()}`,
agent: input.agent,
model: input.model,
version: "",
+2 -3
View File
@@ -58,9 +58,8 @@ export async function collectNodeAssets(target: NodeTarget) {
source: path.join(ptyRoot, relative),
})),
]
const unique = [...new Map(assets.map((asset) => [asset.key, asset])).values()]
await Promise.all(unique.map((asset) => stat(asset.source)))
return unique
await Promise.all(assets.map((asset) => stat(asset.source)))
return assets
}
export async function hashNodeAssets(assets: readonly NodeAsset[]) {
+3 -2
View File
@@ -8,7 +8,6 @@ import {
type SessionMessageInfo,
type SkillInfo,
} from "@opencode-ai/client/promise"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import type {
AgentSideConnection,
AuthenticateRequest,
@@ -214,7 +213,9 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
sessions: page.data.map((session) => ({
sessionId: session.id,
cwd: session.location.directory,
title: withTimestampedFallback(session),
title:
session.title ??
`${session.parentID ? "Child" : "New"} session - ${new Date(session.time.created).toISOString()}`,
updatedAt: new Date(session.time.updated).toISOString(),
})),
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
+1
View File
@@ -133,6 +133,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
description: "Manage plugins",
commands: [Spec.make("list", { description: "List active plugins" })],
}),
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
Spec.make("mini", {
description: "Start the minimal interactive interface",
params: {
@@ -0,0 +1,5 @@
import { Effect } from "effect"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
export default Runtime.handler(Commands.commands.migrate, (_input) => Effect.log("No migrations to run."))
+7
View File
@@ -116,6 +116,13 @@ export function migrateV1(legacy: TuiConfigV1.Info | undefined, kv: Record<strin
: { grouping: kv.exploration_grouping ? ("auto" as const) : ("none" as const) }),
},
}),
...(kv.dismissed_getting_started === undefined
? {}
: {
hints: {
onboarding: !kv.dismissed_getting_started,
},
}),
...(kv.animations_enabled === undefined ? {} : { animations: kv.animations_enabled }),
...(legacy?.mouse === undefined ? {} : { mouse: legacy.mouse }),
}
+1
View File
@@ -35,6 +35,7 @@ const Handlers = Runtime.handlers(Commands, {
plugin: {
list: () => import("./commands/handlers/plugin/list"),
},
migrate: () => import("./commands/handlers/migrate"),
mini: () => import("./commands/handlers/mini"),
run: () => import("./commands/handlers/run"),
pair: () => import("./commands/handlers/pair"),
+4
View File
@@ -235,6 +235,10 @@ async function renderToolError(part: SessionMessageAssistantTool, directory: str
UI.println(UI.Style.TEXT_NORMAL + "✗", UI.Style.TEXT_NORMAL + `${info.title} failed`)
}
function warning(message: string) {
UI.println(UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_NORMAL, message)
}
function errorMessage(error: unknown) {
if (error instanceof Error) return error.message
if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string")
+1 -1
View File
@@ -74,12 +74,12 @@ test("migrates tui and kv config into cli.json", async () => {
terminal: { title: false },
prompt: { editor: false, paste: "full" },
session: { sidebar: "hide", scrollbar: true, thinking: "show", grouping: "none" },
hints: { onboarding: false },
animations: false,
mouse: false,
})
expect(config).not.toHaveProperty("skipped_version")
expect(config).not.toHaveProperty("which_key")
expect(config).not.toHaveProperty("hints")
expect((await Bun.file(path.join(directory, "cli.json")).json()).keybinds).toEqual({ leader: "ctrl+o" })
expect(await Bun.file(path.join(directory, "cli.json")).exists()).toBe(true)
expect(await Bun.file(path.join(directory, "tui.json")).exists()).toBe(true)
-17
View File
@@ -1,17 +0,0 @@
import { expect, test } from "bun:test"
import { fileURLToPath } from "node:url"
import { collectNodeAssets } from "../script/node-assets"
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
test("collects each SEA asset key once", async () => {
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
},
])
})
+5 -6
View File
@@ -118,7 +118,6 @@ export type SessionListOperation<E = never> = (input?: Endpoint5_0Input) => Effe
export type Endpoint5_1Input = {
readonly id?: Session.ID | undefined
readonly title?: string | undefined
readonly agent?: Agent.ID | undefined
readonly model?: Model.Ref | undefined
readonly location?: Location.Ref | undefined
@@ -398,7 +397,7 @@ export type Endpoint5_26Output =
readonly location?: Location.Ref | undefined
readonly data: {
readonly sessionID: Session.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
}
}
| {
@@ -524,7 +523,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
readonly tokens?:
| {
@@ -686,7 +685,7 @@ export type Endpoint5_26Output =
readonly sessionID: Session.ID
readonly assistantMessageID: SessionMessage.ID
readonly callID: string
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
readonly content?:
| readonly [
(
@@ -726,7 +725,7 @@ export type Endpoint5_26Output =
readonly assistantMessageID: SessionMessage.ID
readonly attempt: number
readonly at: number
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
}
}
| {
@@ -776,7 +775,7 @@ export type Endpoint5_26Output =
readonly data: {
readonly sessionID: Session.ID
readonly reason: "auto" | "manual"
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
readonly error: { readonly type: string; readonly message: string }
readonly inputID?: SessionMessage.ID | undefined
}
}
@@ -299,13 +299,7 @@ const Endpoint5_0 = (raw: RawClient["server.session"]) => (input?: Endpoint5_0In
const Endpoint5_1 = (raw: RawClient["server.session"]) => (input?: Endpoint5_1Input) =>
preserveEffect<Endpoint5_1Output>()(
raw["session.create"]({
payload: {
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
},
payload: { id: input?.["id"], agent: input?.["agent"], model: input?.["model"], location: input?.["location"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
@@ -462,7 +462,6 @@ export function make(options: ClientOptions) {
path: `/api/session`,
body: {
id: input?.["id"],
title: input?.["title"],
agent: input?.["agent"],
model: input?.["model"],
location: input?.["location"],
+1 -12
View File
@@ -108,7 +108,7 @@ export type ToolTextContent = { type: "text"; text: string }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
export type SessionStructuredError = { type: string; message: string; status?: number }
export type SessionStructuredError = { type: string; message: string }
export type SessionMessageCompactionRunning = {
type: "compaction"
@@ -2662,35 +2662,24 @@ export type SessionListOutput = SessionsResponse
export type SessionCreateInput = {
readonly id?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["id"]
readonly title?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["title"]
readonly agent?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["agent"]
readonly model?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
}["model"]
readonly location?: {
readonly id?: string | null
readonly title?: string | null
readonly agent?: string | null
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
+3
View File
@@ -56,6 +56,7 @@
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/semver": "catalog:",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
"@parcel/watcher-darwin-arm64": "2.5.1",
@@ -99,6 +100,7 @@
"@ff-labs/fff-node": "0.10.1",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/effect-drizzle-sqlite": "workspace:*",
"@opencode-ai/effect-sqlite-node": "workspace:*",
"@opencode-ai/ai": "workspace:*",
"@opencode-ai/schema": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -120,6 +122,7 @@
"immer": "11.1.4",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"semver": "^7.6.3",
"turndown": "7.2.0",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
+18 -59
View File
@@ -1,13 +1,10 @@
export * as AISDKNative from "./aisdk-native"
import { isRecord } from "@opencode-ai/ai/utils/record"
import { Provider } from "./provider"
export interface Mapping {
readonly package: string
readonly settings: Readonly<Record<string, unknown>>
readonly headers?: Readonly<Record<string, string>>
readonly body?: Readonly<Record<string, unknown>>
}
export function map(packageName: string | undefined, settings: Readonly<Record<string, unknown>>): Mapping | undefined {
@@ -23,7 +20,14 @@ export function map(packageName: string | undefined, settings: Readonly<Record<s
},
}
case "@openrouter/ai-sdk-provider":
return mapOpenRouter(settings, baseSettings)
return {
package: "@opencode-ai/ai/providers/openrouter",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapProviderOptions("openrouter", settings),
},
}
case "@ai-sdk/xai":
return {
package: "@opencode-ai/ai/providers/xai",
@@ -65,61 +69,6 @@ function mapGoogleOptions(settings: Readonly<Record<string, unknown>>) {
return { providerOptions: { gemini: options } }
}
function mapOpenRouter(
settings: Readonly<Record<string, unknown>>,
baseSettings: Readonly<Record<string, unknown>>,
): Mapping {
const headers =
Provider.mergeHeaders(
{
...(typeof settings.appName === "string" ? { "X-OpenRouter-Title": settings.appName } : {}),
...(typeof settings.appUrl === "string" ? { "HTTP-Referer": settings.appUrl } : {}),
...(isStringRecord(settings.api_keys) && Object.keys(settings.api_keys).length > 0
? { "X-Provider-API-Keys": JSON.stringify(settings.api_keys) }
: {}),
},
isStringRecord(settings.headers) ? settings.headers : undefined,
) ?? {}
return {
package: "@opencode-ai/ai/providers/openrouter",
settings: {
...baseSettings,
...mapAPIKey(settings),
...mapOpenRouterOptions(settings),
},
...(Object.keys(headers).length > 0 ? { headers } : {}),
...(isRecord(settings.extraBody) ? { body: settings.extraBody } : {}),
}
}
function mapOpenRouterOptions(settings: Readonly<Record<string, unknown>>) {
const options = Object.fromEntries(
Object.entries(settings).filter(
([key]) =>
![
"apiKey",
"api_keys",
"appName",
"appUrl",
"authToken",
"baseURL",
"chunkTimeout",
"compatibility",
"extraBody",
"fetch",
"headers",
"timeout",
].includes(key),
),
)
if (Object.keys(options).length === 0) return {}
return { providerOptions: { openrouter: options } }
}
function isStringRecord(value: unknown): value is Readonly<Record<string, string>> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string")
}
function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
const options = {
...(typeof settings.reasoningEffort === "string" ? { reasoningEffort: settings.reasoningEffort } : {}),
@@ -129,3 +78,13 @@ function mapXAIOptions(settings: Readonly<Record<string, unknown>>) {
if (Object.keys(options).length === 0) return {}
return { providerOptions: { xai: options } }
}
function mapProviderOptions(namespace: string, settings: Readonly<Record<string, unknown>>) {
const values = Object.fromEntries(
Object.entries(settings).filter(
([key]) => !["apiKey", "authToken", "baseURL", "chunkTimeout", "fetch", "timeout"].includes(key),
),
)
if (Object.keys(values).length === 0) return {}
return { providerOptions: { [namespace]: values } }
}
+16 -37
View File
@@ -1,6 +1,5 @@
export * as AISDK from "./aisdk"
import { AsyncLocalStorage } from "node:async_hooks"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import type {
JSONSchema7,
@@ -30,7 +29,7 @@ import {
type ToolDefinition,
type UsageInput,
} from "@opencode-ai/ai"
import { Auth, Endpoint, type AnyRoute, type HttpHandler, type HttpMiddleware } from "@opencode-ai/ai/route"
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
import type { ID, Info } from "./model"
@@ -104,7 +103,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
})
}
function prepareOptions(model: Info, pkg: string, requests: AsyncLocalStorage<HttpMiddleware>) {
function prepareOptions(model: Info, pkg: string) {
const projected = mapBodyToProviderOptions(model, pkg)
const options: Record<string, any> = {
name: model.providerID,
@@ -151,20 +150,10 @@ function prepareOptions(model: Info, pkg: string, requests: AsyncLocalStorage<Ht
}
}
const requestInit: RequestInit = opts
const request =
input instanceof Request
? new Request(input, requestInit)
: input instanceof URL
? new Request(input.href, requestInit)
: new Request(input, requestInit)
const handler: HttpHandler = (input) =>
Effect.tryPromise({
try: () => (typeof customFetch === "function" ? customFetch(input) : fetch(input)),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
const middleware = requests.getStore()
const res = await Effect.runPromise(middleware ? middleware(request, handler) : handler(request))
const res = await (typeof customFetch === "function" ? customFetch : fetch)(input, {
...opts,
timeout: false,
})
if (!chunkAbortCtl || typeof chunkTimeout !== "number") return res
return wrapSSE(res, chunkTimeout, chunkAbortCtl)
}
@@ -205,7 +194,6 @@ export const locationLayer = Layer.effect(
let languageHooks: ((event: LanguageEvent) => Effect.Effect<void> | void)[] = []
const languages = new Map<string, LanguageModelV3>()
const sdks = new Map<string, SDK>()
const requests = new AsyncLocalStorage<HttpMiddleware>()
const functionIDs = new WeakMap<object, number>()
let nextFunctionID = 0
const cacheKey = (input: unknown) =>
@@ -279,7 +267,7 @@ export const locationLayer = Layer.effect(
})
const packageName = Provider.packageName(model.package)
const options = prepareOptions(model, packageName, requests)
const options = prepareOptions(model, packageName)
const sdkKey = cacheKey({
providerID: model.providerID,
package: packageName,
@@ -304,14 +292,16 @@ export const locationLayer = Layer.effect(
return language
}),
model: Effect.fn("AISDK.model")(function* (model) {
return modelFromLanguage(model, yield* service.language(model), requests)
return modelFromLanguage(model, yield* service.language(model))
}),
})
return service
}),
)
function modelFromLanguage(info: Info, language: LanguageModelV3, requests: AsyncLocalStorage<HttpMiddleware>) {
export const defaultLayer = locationLayer
function modelFromLanguage(info: Info, language: LanguageModelV3) {
const packageName = Provider.packageName(info.package!)
const projected = mapBodyToProviderOptions(info, packageName)
const optionKey = providerOptionKey(packageName, info.providerID)
@@ -342,7 +332,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3, requests: Asyn
body: projected.body === undefined ? undefined : { ...projected.body },
headers: info.headers,
},
limits: { context: info.limit.context, input: info.limit.input, output: info.limit.output },
limits: { context: info.limit.context, output: info.limit.output },
providerOptions,
},
body: {
@@ -351,11 +341,8 @@ function modelFromLanguage(info: Info, language: LanguageModelV3, requests: Asyn
},
with: () => route,
model: (input) => Model.make({ ...input, provider: "provider" in input ? input.provider : info.providerID, route }),
prepareTransport: (body, _request, options) => Effect.succeed({ body, middleware: options?.http }),
streamPrepared: (prepared) => {
const input = prepared as { body: LanguageModelV3CallOptions; middleware?: HttpMiddleware }
return streamLanguage(language, input.body, requests, input.middleware)
},
prepareTransport: (body) => Effect.succeed(body),
streamPrepared: (prepared) => streamLanguage(language, prepared as LanguageModelV3CallOptions),
}
return Model.make({
id: info.modelID ?? info.id,
@@ -544,21 +531,13 @@ function providerOptions(input: LLMRequest["providerOptions"]): SharedV3Provider
return Object.fromEntries(Object.entries(input).map(([key, value]) => [key, jsonObject(value)]))
}
function streamLanguage(
language: LanguageModelV3,
options: LanguageModelV3CallOptions,
requests?: AsyncLocalStorage<HttpMiddleware>,
middleware?: HttpMiddleware,
) {
function streamLanguage(language: LanguageModelV3, options: LanguageModelV3CallOptions) {
const state = { step: 0, toolNames: {} as Record<string, string> }
return Stream.concat(
Stream.make(LLMEvent.stepStart({ index: state.step })),
Stream.unwrap(
Effect.tryPromise({
try: () =>
requests && middleware
? requests.run(middleware, () => language.doStream(options))
: language.doStream(options),
try: () => language.doStream(options),
catch: (error) => llmError("doStream", error),
}).pipe(
Effect.map((result) =>
+3 -3
View File
@@ -16,7 +16,7 @@ import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigAgent } from "./config/agent"
import { ConfigMedia } from "./config/media"
import { ConfigAttachments } from "./config/attachments"
import { ConfigCompaction } from "./config/compaction"
import { ConfigCommand } from "./config/command"
import { ConfigExperimental } from "./config/experimental"
@@ -85,8 +85,8 @@ export class Info extends Schema.Class<Info>("Config.Info")({
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
description: "Media processing configuration",
attachments: ConfigAttachments.Info.pipe(Schema.optional).annotate({
description: "Attachment processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
description: "Tool output truncation thresholds",
@@ -1,15 +1,15 @@
export * as ConfigMedia from "./media"
export * as ConfigAttachments from "./attachments"
import { Schema } from "effect"
import { PositiveInt } from "../schema"
export class Image extends Schema.Class<Image>("Config.Media.Image")({
export class Image extends Schema.Class<Image>("Config.Attachments.Image")({
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Media")({
export class Info extends Schema.Class<Info>("Config.Attachments")({
image: Image.pipe(Schema.optional),
}) {}
@@ -0,0 +1,181 @@
export * as MoveSession from "./move-session"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Git } from "../git"
import { Global } from "@opencode-ai/util/global"
import { Project } from "../project"
import { Session } from "../session"
import { SessionExecution } from "../session/execution"
import { SessionSchema } from "../session/schema"
import { SessionStore } from "../session/store"
import { AbsolutePath } from "../schema"
import path from "path"
export const Destination = Schema.Struct({
directory: AbsolutePath,
}).annotate({ identifier: "MoveSession.Destination" })
export type Destination = typeof Destination.Type
export const Input = Schema.Struct({
sessionID: SessionSchema.ID,
destination: Destination,
moveChanges: Schema.optional(Schema.Boolean),
}).annotate({ identifier: "MoveSession.Input" })
export type Input = typeof Input.Type
export class DestinationProjectMismatchError extends Schema.TaggedErrorClass<DestinationProjectMismatchError>()(
"MoveSession.DestinationProjectMismatchError",
{
expected: Project.ID,
actual: Project.ID,
},
) {}
export class DestinationNotFoundError extends Schema.TaggedErrorClass<DestinationNotFoundError>()(
"MoveSession.DestinationNotFoundError",
{ directory: AbsolutePath },
) {}
export class DestinationNotDirectoryError extends Schema.TaggedErrorClass<DestinationNotDirectoryError>()(
"MoveSession.DestinationNotDirectoryError",
{ directory: AbsolutePath },
) {}
export class ApplyChangesError extends Schema.TaggedErrorClass<ApplyChangesError>()("MoveSession.ApplyChangesError", {
message: Schema.String,
}) {}
export class CaptureChangesError extends Schema.TaggedErrorClass<CaptureChangesError>()(
"MoveSession.CaptureChangesError",
{
message: Schema.String,
},
) {}
export class ResetSourceChangesError extends Schema.TaggedErrorClass<ResetSourceChangesError>()(
"MoveSession.ResetSourceChangesError",
{
directory: AbsolutePath,
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
},
) {}
export type Error =
| Session.NotFoundError
| DestinationProjectMismatchError
| DestinationNotFoundError
| DestinationNotDirectoryError
| Session.DestinationNotFoundError
| Session.DestinationNotDirectoryError
| CaptureChangesError
| ApplyChangesError
| ResetSourceChangesError
export interface Interface {
readonly moveSession: (input: Input) => Effect.Effect<void, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ControlPlaneMoveSession") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const git = yield* Git.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const project = yield* Project.Service
const sessions = yield* SessionStore.Service
const session = yield* Session.Service
const execution = yield* SessionExecution.Service
const moveSession = Effect.fn("MoveSession.moveSession")(function* (input: Input) {
const current = yield* sessions.get(input.sessionID)
if (!current) return yield* new Session.NotFoundError({ sessionID: input.sessionID })
const value = input.destination.directory.trim()
const expanded = value === "~" ? global.home : value.startsWith("~/") ? path.join(global.home, value.slice(2)) : value
const directory = AbsolutePath.make(path.resolve(current.location.directory, expanded))
const destinationInfo = yield* fs.stat(directory).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!destinationInfo) return yield* new DestinationNotFoundError({ directory })
if (destinationInfo.type !== "Directory") return yield* new DestinationNotDirectoryError({ directory })
if (current.location.directory === directory) return
const source = yield* project.resolve(current.location.directory)
const destination = yield* project.resolve(directory)
if (input.moveChanges && current.projectID !== destination.id) {
return yield* new DestinationProjectMismatchError({ expected: current.projectID, actual: destination.id })
}
// A move must not race active execution: a mid-drain relocation would let
// the source Location dispatch a request assembled under stale instructions
// and history. Serialize like removal does — stop the drain, then move.
yield* execution.interrupt(input.sessionID)
yield* execution.awaitIdle(input.sessionID)
const moveChanges = input.moveChanges && source.directory !== destination.directory
const sourceRepository = moveChanges ? yield* git.repo.discover(current.location.directory) : undefined
if (moveChanges && !sourceRepository)
return yield* new CaptureChangesError({ message: "Source is not a Git repository" })
const patch = sourceRepository
? yield* git.change
.capture({ repository: sourceRepository, path: current.location.directory })
.pipe(Effect.mapError((error) => new CaptureChangesError({ message: error.message })))
: Git.ChangeSet.make("")
if (patch) {
const repository = yield* git.repo.discover(directory)
if (!repository) return yield* new ApplyChangesError({ message: "Destination is not a Git repository" })
yield* git.change
.apply({ repository, path: directory, changes: patch })
.pipe(Effect.mapError((error) => new ApplyChangesError({ message: error.message })))
}
yield* session.move({
sessionID: input.sessionID,
directory,
})
if (patch) {
const repository = yield* git.repo.discover(current.location.directory)
if (!repository)
return yield* new ResetSourceChangesError({
directory: current.location.directory,
message: "Source is not a Git repository",
})
yield* git.change
.discard({
repository,
path: current.location.directory,
index: "preserve",
untracked: "remove",
})
.pipe(
Effect.mapError(
(error) =>
new ResetSourceChangesError({
directory: current.location.directory,
message: error.message,
cause: error.cause,
}),
),
)
}
})
return Service.of({ moveSession })
}),
)
export const node = makeGlobalNode({
service: Service,
layer,
deps: [
FSUtil.node,
Git.node,
Global.node,
Project.node,
Session.node,
SessionStore.node,
SessionExecution.node,
],
})
@@ -1,3 +1,4 @@
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const imageGenerationArgsSchema = z
@@ -23,3 +24,91 @@ export const imageGenerationArgsSchema = z
export const imageGenerationOutputSchema = z.object({
result: z.string(),
})
type ImageGenerationArgs = {
/**
* Background type for the generated image. Default is 'auto'.
*/
background?: "auto" | "opaque" | "transparent"
/**
* Input fidelity for the generated image. Default is 'low'.
*/
inputFidelity?: "low" | "high"
/**
* Optional mask for inpainting.
* Contains image_url (string, optional) and file_id (string, optional).
*/
inputImageMask?: {
/**
* File ID for the mask image.
*/
fileId?: string
/**
* Base64-encoded mask image.
*/
imageUrl?: string
}
/**
* The image generation model to use. Default: gpt-image-1.
*/
model?: string
/**
* Moderation level for the generated image. Default: auto.
*/
moderation?: "auto"
/**
* Compression level for the output image. Default: 100.
*/
outputCompression?: number
/**
* The output format of the generated image. One of png, webp, or jpeg.
* Default: png
*/
outputFormat?: "png" | "jpeg" | "webp"
/**
* Number of partial images to generate in streaming mode, from 0 (default value) to 3.
*/
partialImages?: number
/**
* The quality of the generated image.
* One of low, medium, high, or auto. Default: auto.
*/
quality?: "auto" | "low" | "medium" | "high"
/**
* The size of the generated image.
* One of 1024x1024, 1024x1536, 1536x1024, or auto.
* Default: auto.
*/
size?: "auto" | "1024x1024" | "1024x1536" | "1536x1024"
}
const imageGenerationToolFactory = createProviderToolFactoryWithOutputSchema<
{},
{
/**
* The generated image encoded in base64.
*/
result: string
},
ImageGenerationArgs
>({
id: "openai.image_generation",
inputSchema: z.object({}),
outputSchema: imageGenerationOutputSchema,
})
export const imageGeneration = (
args: ImageGenerationArgs = {}, // default
) => {
return imageGenerationToolFactory(args)
}
@@ -1,3 +1,4 @@
import { createProviderToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const localShellInputSchema = z.object({
@@ -14,3 +15,50 @@ export const localShellInputSchema = z.object({
export const localShellOutputSchema = z.object({
output: z.string(),
})
export const localShell = createProviderToolFactoryWithOutputSchema<
{
/**
* Execute a shell command on the server.
*/
action: {
type: "exec"
/**
* The command to run.
*/
command: string[]
/**
* Optional timeout in milliseconds for the command.
*/
timeoutMs?: number
/**
* Optional user to run the command as.
*/
user?: string
/**
* Optional working directory to run the command in.
*/
workingDirectory?: string
/**
* Environment variables to set for the command.
*/
env?: Record<string, string>
}
},
{
/**
* The output of local shell tool call.
*/
output: string
},
{}
>({
id: "openai.local_shell",
inputSchema: localShellInputSchema,
outputSchema: localShellOutputSchema,
})
@@ -1,3 +1,4 @@
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
// Args validation schema
@@ -38,3 +39,65 @@ export const webSearchPreviewArgsSchema = z.object({
})
.optional(),
})
export const webSearchPreview = createProviderToolFactory<
{
// Web search doesn't take input parameters - it's controlled by the prompt
},
{
/**
* Search context size to use for the web search.
* - high: Most comprehensive context, highest cost, slower response
* - medium: Balanced context, cost, and latency (default)
* - low: Least context, lowest cost, fastest response
*/
searchContextSize?: "low" | "medium" | "high"
/**
* User location information to provide geographically relevant search results.
*/
userLocation?: {
/**
* Type of location (always 'approximate')
*/
type: "approximate"
/**
* Two-letter ISO country code (e.g., 'US', 'GB')
*/
country?: string
/**
* City name (free text, e.g., 'Minneapolis')
*/
city?: string
/**
* Region name (free text, e.g., 'Minnesota')
*/
region?: string
/**
* IANA timezone (e.g., 'America/Chicago')
*/
timezone?: string
}
}
>({
id: "openai.web_search_preview",
inputSchema: z.object({
action: z
.discriminatedUnion("type", [
z.object({
type: z.literal("search"),
query: z.string().nullish(),
}),
z.object({
type: z.literal("open_page"),
url: z.string(),
}),
z.object({
type: z.literal("find"),
url: z.string(),
pattern: z.string(),
}),
])
.nullish(),
}),
})
@@ -1,3 +1,4 @@
import { createProviderToolFactory } from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
export const webSearchArgsSchema = z.object({
@@ -19,3 +20,83 @@ export const webSearchArgsSchema = z.object({
})
.optional(),
})
export const webSearchToolFactory = createProviderToolFactory<
{
// Web search doesn't take input parameters - it's controlled by the prompt
},
{
/**
* Filters for the search.
*/
filters?: {
/**
* Allowed domains for the search.
* If not provided, all domains are allowed.
* Subdomains of the provided domains are allowed as well.
*/
allowedDomains?: string[]
}
/**
* Search context size to use for the web search.
* - high: Most comprehensive context, highest cost, slower response
* - medium: Balanced context, cost, and latency (default)
* - low: Least context, lowest cost, fastest response
*/
searchContextSize?: "low" | "medium" | "high"
/**
* User location information to provide geographically relevant search results.
*/
userLocation?: {
/**
* Type of location (always 'approximate')
*/
type: "approximate"
/**
* Two-letter ISO country code (e.g., 'US', 'GB')
*/
country?: string
/**
* City name (free text, e.g., 'Minneapolis')
*/
city?: string
/**
* Region name (free text, e.g., 'Minnesota')
*/
region?: string
/**
* IANA timezone (e.g., 'America/Chicago')
*/
timezone?: string
}
}
>({
id: "openai.web_search",
inputSchema: z.object({
action: z
.discriminatedUnion("type", [
z.object({
type: z.literal("search"),
query: z.string().nullish(),
}),
z.object({
type: z.literal("open_page"),
url: z.string(),
}),
z.object({
type: z.literal("find"),
url: z.string(),
pattern: z.string(),
}),
])
.nullish(),
}),
})
export const webSearch = (
args: Parameters<typeof webSearchToolFactory>[0] = {}, // default
) => {
return webSearchToolFactory(args)
}
+1 -1
View File
@@ -61,7 +61,7 @@ const layer = Layer.effect(
const image = Object.assign(
{},
...(yield* config.entries()).flatMap((entry) =>
entry.type === "document" && entry.info.media?.image ? [entry.info.media.image] : [],
entry.type === "document" && entry.info.attachments?.image ? [entry.info.attachments.image] : [],
),
)
const normalize = yield* loadAdapter
+3
View File
@@ -148,3 +148,6 @@ export function buildLocationServiceMap(
),
)
}
// This is temporary for backwards compatibility
export const locationServiceMapLayer = buildLocationServiceMap()
+11 -6
View File
@@ -81,7 +81,7 @@ const withDefaults = (model: Info, route: AnyRoute) =>
headers: providerHeaders(model),
providerOptions: providerOptions(model),
http: model.body === undefined ? undefined : { body: model.body },
limits: { context: model.limit.context, input: model.limit.input, output: model.limit.output },
limits: { context: model.limit.context, output: model.limit.output },
})
const providerHeaders = (model: Info) => {
@@ -96,7 +96,9 @@ const providerHeaders = (model: Info) => {
return Provider.mergeHeaders(generated.size === 0 ? undefined : Object.fromEntries(generated), model.headers)
}
const providerOptions = (model: Info): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
const providerOptions = (
model: Info,
): { readonly [key: string]: { readonly [key: string]: unknown } } | undefined => {
if (!Provider.isAISDK(model.package) || model.settings === undefined) return undefined
const { apiKey: _, baseURL: _baseURL, ...settings } = model.settings
if (Object.keys(settings).length === 0) return undefined
@@ -200,9 +202,9 @@ export const fromCatalogModel = (
const settings = {
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
...nativeCredentialSettings(specifier, credential),
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
body: Provider.mergeOverlay(mapping?.body, resolved.body),
limits: { context: resolved.limit.context, input: resolved.limit.input, output: resolved.limit.output },
headers: resolved.headers,
body: resolved.body,
limits: { context: resolved.limit.context, output: resolved.limit.output },
}
return yield* Effect.try({
try: () => {
@@ -264,7 +266,10 @@ export const layer = Layer.effect(
const integrations = yield* Integration.Service
const npm = yield* Npm.Service
const aisdk = yield* AISDK.Service
const load = Effect.fn("ModelResolver.resolveModel")(function* (selected: Info, variant?: VariantID) {
const load = Effect.fn("ModelResolver.resolveModel")(function* (
selected: Info,
variant?: VariantID,
) {
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
-3
View File
@@ -341,7 +341,6 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
create: (input) =>
runtime.session.create({
id: input?.id,
title: input?.title,
agent: input?.agent,
model: input?.model,
location:
@@ -351,10 +350,8 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
prompt: runtime.session.prompt,
generate: (input) => runtime.session.generate(input).pipe(Effect.map((text) => ({ text }))),
command: runtime.session.command,
rename: runtime.session.rename,
synthetic: runtime.session.synthetic,
interrupt: (input) => runtime.session.interrupt(input.sessionID),
wait: (input) => runtime.session.wait(input.sessionID),
},
} satisfies Plugin.Context
})
@@ -0,0 +1,94 @@
export * as LayerMapExample from "./layer-map.example"
import { Context, Effect, Layer, LayerMap } from "effect"
import { Npm } from "@opencode-ai/util/npm"
/**
* Tutorial: split global services from context-specific services.
*
* Use this pattern when part of the app should be constructed once at the app edge,
* while another part should be cached per request/project/workspace key.
*
* In this example:
* - Npm.Service is the global service. It is not keyed by request context and should
* be provided once by the application runtime.
* - ConfigService is context-specific. It is built from a RequestContext key and is
* cached by LayerMap for that key.
* - ConfigServiceMap.layer owns the cache. Provide it once globally, then each
* request can provide ConfigServiceMap.get(context) to select the right instance.
*
* Lifetime model:
* - ConfigServiceMap.layer has the app/global lifetime and depends on Npm.Service.
* - ConfigServiceMap.get(context) has the request/context lifetime and provides
* ConfigService for exactly that context key.
* - The cached ConfigService entry stays alive while something is using it. Once idle,
* it remains cached for idleTimeToLive, then its scope is finalized.
* - invalidate(context) removes the cache entry for future lookups. Active users keep
* running on the old instance; the next lookup can create a fresh instance.
*
* Key model:
* - Keys can be strings, structs, classes, arrays, etc.
* - Prefer primitive or immutable keys. Effect uses Hash / Equal semantics for cache
* lookup, so mutating an object after it has been used as a key is a bug.
*/
export type RequestContext = {
readonly directory: string
readonly workspace: string
}
export class RequestContextRef extends Context.Service<RequestContextRef, RequestContext>()(
"@opencode/example/RequestContextRef",
) {}
export interface ConfigServiceShape {
readonly directory: string
readonly workspace: string
readonly nextUse: () => Effect.Effect<number>
readonly which: Npm.Interface["which"]
}
export class ConfigService extends Context.Service<ConfigService, ConfigServiceShape>()(
"@opencode/example/ConfigService",
) {}
const configServiceLayer = Layer.effect(
ConfigService,
Effect.gen(function* () {
const context = yield* RequestContextRef
const npm = yield* Npm.Service
let useCount = 0
return ConfigService.of({
directory: context.directory,
workspace: context.workspace,
nextUse: () => Effect.succeed(++useCount),
which: npm.which,
})
}),
)
export class ConfigServiceMap extends LayerMap.Service<ConfigServiceMap>()("@opencode/example/ConfigServiceMap", {
lookup: (context: RequestContext) =>
configServiceLayer.pipe(Layer.provide(Layer.succeed(RequestContextRef, RequestContextRef.of(context)))),
idleTimeToLive: "5 minutes",
}) {}
export const appLayer = ConfigServiceMap.layer
export const readConfig = Effect.fn("LayerMapExample.readConfig")(function* () {
const config = yield* ConfigService
return {
directory: config.directory,
workspace: config.workspace,
useCount: yield* config.nextUse(),
}
})
export const handleRequest = Effect.fn("LayerMapExample.handleRequest")(function* (context: RequestContext) {
return yield* readConfig().pipe(Effect.provide(ConfigServiceMap.get(context)))
})
export const invalidateContext = (context: RequestContext) => ConfigServiceMap.invalidate(context)
+3 -31
View File
@@ -194,9 +194,7 @@ export function fromPromise(plugin: Plugin) {
),
),
refresh:
refresh === undefined
? undefined
: (credential) => Effect.promise(() => refresh(credential)),
refresh === undefined ? undefined : (credential) => Effect.promise(() => refresh(credential)),
})
},
remove: draft.method.remove,
@@ -265,34 +263,8 @@ export function fromPromise(plugin: Plugin) {
),
},
session: {
hook: (name, callback) => {
if (name !== "http")
return register(
host.session.hook(name, (event) =>
Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [event]))),
),
)
return register(
host.session.hook("http", (event) => {
const request = event.request
const output = {
...event,
request: (input: Request) => Effect.runPromiseWith(context)(request(input)),
}
return Effect.promise(() => Promise.resolve(Reflect.apply(callback, undefined, [output]))).pipe(
Effect.tap(() =>
Effect.sync(() => {
event.request = (input) =>
Effect.tryPromise({
try: () => output.request(input),
catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))),
})
}),
),
)
}),
)
},
hook: (name, callback) =>
register(host.session.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
create: (input) =>
run(
host.session.create(
+12 -18
View File
@@ -220,30 +220,24 @@ export const OpenAIPlugin = define({
return
}
draft.cost = []
// Match Codex CLI so context consumption and subscription usage stay consistent between clients.
draft.limit = { ...draft.limit, context: 272_000, input: 272_000 }
if (draft.id.includes("gpt-5.5")) {
draft.limit = { context: 400_000, input: 272_000, output: 128_000 }
}
if (draft.id.includes("gpt-5.6")) {
draft.limit = { context: 500_000, input: 372_000, output: 128_000 }
}
})
}
})
yield* ctx.session.hook("http", (evt) =>
yield* ctx.session.hook("request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const request = evt.request
evt.request = (input) => {
const url = new URL(input.url)
const headers = new Headers(input.headers)
headers.set("originator", "opencode")
headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return request(new Request(input, { headers }))
return request(
new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, {
method: input.method,
headers,
body: input.body,
signal: input.signal,
}),
)
const url = new URL(evt.url)
if (url.origin === "https://api.openai.com") {
evt.url = `${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`
}
evt.headers.originator = "opencode"
evt.headers["session-id"] = evt.sessionID
}),
)
+2 -13
View File
@@ -11,17 +11,7 @@ import { Session } from "../session"
export interface Interface {
readonly session: Pick<
Session.Interface,
| "get"
| "create"
| "messages"
| "prompt"
| "generate"
| "command"
| "rename"
| "resume"
| "interrupt"
| "synthetic"
| "wait"
"get" | "create" | "messages" | "prompt" | "generate" | "command" | "resume" | "interrupt" | "synthetic"
>
readonly job: Pick<Job.Interface, "start" | "wait" | "block" | "background" | "cancel">
readonly location: {
@@ -62,11 +52,9 @@ export const layerWithCell = (cell: Cell) =>
prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)),
generate: (input) => require(cell, (runtime) => runtime.session.generate(input)),
command: (input) => require(cell, (runtime) => runtime.session.command(input)),
rename: (input) => require(cell, (runtime) => runtime.session.rename(input)),
resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)),
interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)),
synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)),
wait: (sessionID) => require(cell, (runtime) => runtime.session.wait(sessionID)),
},
job: {
start: (input) => require(cell, (runtime) => runtime.job.start(input)),
@@ -120,6 +108,7 @@ export const providerLayerWithCell = (cell: Cell) =>
)
export const layer = layerWithCell(defaultCell)
export const providerLayer = providerLayerWithCell(defaultCell)
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
+3 -3
View File
@@ -938,7 +938,7 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
: resolved.bytes
const normalized = yield* normalizeImageAttachment(
input,
Buffer.from(content).toString("base64"),
Base64.make(Buffer.from(content).toString("base64")),
mime,
image,
)
@@ -954,11 +954,11 @@ const materializeAttachment = Effect.fn("Session.materializeAttachment")(functio
const normalizeImageAttachment = Effect.fn("Session.normalizeImageAttachment")(function* (
input: PromptInput.FileAttachment,
data: string,
data: Base64,
mime: string,
image: Effect.Effect<Image.Interface>,
) {
if (!mime.startsWith("image/")) return { data: Base64.make(data), mime }
if (!mime.startsWith("image/")) return { data, mime }
const service = yield* image
const label = input.name ?? (input.uri.startsWith("data:") ? "inline attachment" : input.uri)
const content = { uri: label, content: data, encoding: "base64" as const, mime }
+10 -12
View File
@@ -152,11 +152,14 @@ const settings = (documents: readonly Config.Entry[]) => {
const configured = documents
.filter((entry): entry is Config.Document => entry.type === "document")
.flatMap((entry) => (entry.info.compaction ? [entry.info.compaction] : []))
return {
auto: configured.findLast((value) => value.auto !== undefined)?.auto ?? true,
buffer: configured.findLast((value) => value.buffer !== undefined)?.buffer ?? DEFAULT_BUFFER,
tokens: configured.findLast((value) => value.keep?.tokens !== undefined)?.keep?.tokens ?? DEFAULT_KEEP_TOKENS,
}
return configured.reduce<Settings>(
(result, current) => ({
auto: current.auto ?? result.auto,
buffer: current.buffer ?? result.buffer,
tokens: current.keep?.tokens ?? result.tokens,
}),
{ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS },
)
}
const select = (
@@ -347,16 +350,11 @@ const make = (dependencies: Dependencies) => {
message.type === "assistant" && message.tokens !== undefined,
)
if (!last) return false
const limits = input.model.route.defaults.limits
const output = Math.min(limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const promptCeiling = Math.min(
limits?.input === undefined ? Number.POSITIVE_INFINITY : limits.input - config.buffer,
context - Math.max(output, config.buffer),
)
const output = Math.min(input.model.route.defaults.limits?.output ?? 0, OUTPUT_TOKEN_MAX)
const used =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (used <= 0) return false
return used >= promptCeiling
return used >= context - (output || config.buffer)
}
const compactManual = Effect.fn("SessionCompaction.compactManual")(function* (input: ManualInput) {
const content = planContent(input.messages, config.tokens)
+1 -1
View File
@@ -71,7 +71,7 @@ export const layer = Layer.effect(
LLM.request({
model: model.model,
http: { headers: SessionModelHeaders.make(selection.session, app) },
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: contextEvent.messages,
tools: hookedTools,
+15 -61
View File
@@ -19,11 +19,6 @@ import { MAX_STEPS_PROMPT } from "./runner/max-steps"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { toLLMMessages } from "./runner/to-llm-message"
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
const IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiB
const IMAGE_REMOVED =
"[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"
/** Failures a prepared execution can surface: infrastructure errors plus user declines resurfaced from the defect tunnel. */
export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
@@ -99,56 +94,6 @@ export const unsupportedParts = (messages: LLMRequest["messages"], capabilities:
}),
)
export const boundImages = (messages: LLMRequest["messages"]) => {
const isImage = (mime: string) => mime.toLowerCase().startsWith("image/")
const size = (data: string | Uint8Array) =>
typeof data === "string" ? Buffer.byteLength(data) : Math.ceil(data.byteLength / 3) * 4
const imageBytes = messages.reduce(
(total, message) =>
total +
message.content.reduce((sum, part) => {
if (part.type === "media" && isImage(part.mediaType)) return sum + size(part.data)
if (part.type !== "tool-result" || part.result.type !== "content") return sum
return (
sum +
part.result.value.reduce(
(bytes: number, item: Content) =>
bytes + (item.type === "file" && isImage(item.mime) ? Buffer.byteLength(item.uri) : 0),
0,
)
)
}, 0),
0,
)
if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
let removed = 0
return messages.map((message) =>
Message.make({
...message,
content: message.content.map((part) => {
if (part.type === "media" && isImage(part.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) {
removed += size(part.data)
return Message.text(IMAGE_REMOVED)
}
if (part.type !== "tool-result" || part.result.type !== "content") return part
return {
...part,
result: {
...part.result,
value: part.result.value.map((item: Content) => {
if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET)
return item
removed += Buffer.byteLength(item.uri)
return { type: "text" as const, text: IMAGE_REMOVED }
}),
},
}
}),
}),
)
}
/**
* Builds an outbound model request and captures the tool-call capability that
* must remain paired with it. It does not execute the request or mutate
@@ -213,22 +158,31 @@ export const layer = Layer.effect(
http: {
headers: SessionModelHeaders.make(session, app),
},
providerOptions: { [providerMetadataKey]: { promptCacheKey } },
providerOptions: { openai: { promptCacheKey } },
system: contextEvent.system,
messages: boundImages(unsupportedParts(contextEvent.messages, resolved.capabilities)),
messages: unsupportedParts(contextEvent.messages, resolved.capabilities),
tools: hookedTools,
toolChoice: stepLimitReached ? "none" : undefined,
})
const options: StreamOptions = {
http: (request, handler) =>
transform: (request) =>
hooks
.trigger("session", "http", {
.trigger("session", "request", {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
request: handler,
...request,
})
.pipe(Effect.flatMap((event) => event.request(request))),
.pipe(
Effect.tap((event) =>
Effect.sync(() => {
request.url = event.url
request.headers = event.headers
request.body = event.body
}),
),
Effect.asVoid,
),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
+1 -5
View File
@@ -6,7 +6,6 @@ import { Agent } from "../agent"
import { Database } from "../database/database"
import { Bus } from "../bus"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { isExactRootFallback } from "@opencode-ai/util/session-title-fallback"
import { App } from "../app"
import { llmClient } from "../effect/app-node-platform"
import { SessionEvent } from "./event"
@@ -40,10 +39,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Se
const truncate = (value: string) => (value.length <= MAX_LENGTH ? value : `${value.slice(0, MAX_LENGTH - 3)}...`)
const isUntitled = (session: SessionSchema.Info) =>
isExactRootFallback({
title: session.title,
time: { created: DateTime.toEpochMillis(session.time.created) },
})
session.title === undefined || session.title === `New session - ${DateTime.formatIso(session.time.created)}`
const make = (dependencies: Dependencies) => {
const generateForFirstPrompt = Effect.fn("SessionTitle.generateForFirstPrompt")(function* (
+10 -16
View File
@@ -11,25 +11,25 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof LLMError) {
switch (cause.reason._tag) {
case "RateLimit":
return providerError("provider.rate-limit", cause.reason)
return { type: "provider.rate-limit", message: cause.reason.message }
case "Authentication":
return providerError("provider.auth", cause.reason)
return { type: "provider.auth", message: cause.reason.message }
case "QuotaExceeded":
return providerError("provider.quota", cause.reason)
return { type: "provider.quota", message: cause.reason.message }
case "ContentPolicy":
return providerError("provider.content-filter", cause.reason)
return { type: "provider.content-filter", message: cause.reason.message }
case "Transport":
return providerError("provider.transport", cause.reason)
return { type: "provider.transport", message: cause.reason.message }
case "ProviderInternal":
return providerError("provider.internal", cause.reason)
return { type: "provider.internal", message: cause.reason.message }
case "InvalidProviderOutput":
return providerError("provider.invalid-output", cause.reason)
return { type: "provider.invalid-output", message: cause.reason.message }
case "InvalidRequest":
return providerError("provider.invalid-request", cause.reason)
return { type: "provider.invalid-request", message: cause.reason.message }
case "NoRoute":
return providerError("provider.no-route", cause.reason)
return { type: "provider.no-route", message: cause.reason.message }
case "UnknownProvider":
return providerError("provider.unknown", cause.reason)
return { type: "provider.unknown", message: cause.reason.message }
default: {
const exhaustive: never = cause.reason
return exhaustive
@@ -58,9 +58,3 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
}
function providerError(type: string, reason: LLMError["reason"]): SessionError.Error {
const status =
("http" in reason ? reason.http?.response?.status : undefined) ?? ("status" in reason ? reason.status : undefined)
return { type, message: reason.message, ...(status === undefined ? {} : { status }) }
}
+10
View File
@@ -0,0 +1,10 @@
import { createRequire } from "node:module"
import path from "node:path"
export namespace Module {
export function resolve(id: string, dir: string) {
try {
return createRequire(path.join(dir, "package.json")).resolve(id)
} catch {}
}
}
+6
View File
@@ -12,6 +12,12 @@ export function getDirectory(path: string | undefined) {
return parts.slice(0, parts.length - 1).join("/") + "/"
}
export function getFileExtension(path: string | undefined) {
if (!path) return ""
const parts = path.split(".")
return parts[parts.length - 1]
}
export function getFilenameTruncated(path: string | undefined, maxLength: number = 20) {
const filename = getFilename(path)
if (filename.length <= maxLength) return filename
@@ -0,0 +1,16 @@
export * as ConfigConsoleStateV1 from "./console-state"
import { Schema } from "effect"
import { NonNegativeInt } from "../../schema"
export class ConsoleState extends Schema.Class<ConsoleState>("ConsoleState")({
consoleManagedProviders: Schema.mutable(Schema.Array(Schema.String)),
activeOrgName: Schema.optional(Schema.String),
switchableOrgCount: NonNegativeInt,
}) {}
export const emptyConsoleState: ConsoleState = ConsoleState.make({
consoleManagedProviders: [],
activeOrgName: undefined,
switchableOrgCount: 0,
})
+1 -1
View File
@@ -63,7 +63,7 @@ export function migrate(info: typeof ConfigV1.Info.Type) {
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
attachments: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
-36
View File
@@ -2,42 +2,6 @@ import { describe, expect, test } from "bun:test"
import { AISDKNative } from "@opencode-ai/core/aisdk-native"
describe("AISDKNative", () => {
test("maps OpenRouter settings to native destinations", () => {
expect(
AISDKNative.map("@openrouter/ai-sdk-provider", {
appName: "OpenCode",
appUrl: "https://opencode.ai",
headers: { "x-openrouter-title": "Configured", "x-provider-api-keys": "Configured BYOK" },
api_keys: { anthropic: "provider-key" },
extraBody: { transforms: ["middle-out"] },
models: ["anthropic/claude-sonnet-4.6"],
provider: { only: ["anthropic"], require_parameters: true },
reasoning: { effort: "high" },
promptCacheKey: "session_123",
future_option: { enabled: true },
}),
).toEqual({
package: "@opencode-ai/ai/providers/openrouter",
settings: {
providerOptions: {
openrouter: {
models: ["anthropic/claude-sonnet-4.6"],
provider: { only: ["anthropic"], require_parameters: true },
reasoning: { effort: "high" },
promptCacheKey: "session_123",
future_option: { enabled: true },
},
},
},
headers: {
"x-openrouter-title": "Configured",
"HTTP-Referer": "https://opencode.ai",
"x-provider-api-keys": "Configured BYOK",
},
body: { transforms: ["middle-out"] },
})
})
test("maps every Google thinking setting", () => {
expect(
AISDKNative.map("@ai-sdk/google", {
-44
View File
@@ -51,50 +51,6 @@ const client = LLMClient.layer.pipe(
),
)
it.effect("applies HTTP middleware to AI SDK requests and responses", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
yield* aisdk.hook.sdk((event) => {
const request = event.options.fetch
event.sdk = {
languageModel: () => ({
...streamModel([]),
doStream: async () => {
const response = await request("https://provider.test/v1/chat", { method: "POST", body: "before" })
const text = await response.text()
return {
stream: new ReadableStream({
start(controller) {
controller.enqueue({ type: "text-start", id: "text" })
controller.enqueue({ type: "text-delta", id: "text", delta: text })
controller.enqueue({ type: "text-end", id: "text" })
controller.enqueue({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage })
controller.close()
},
}),
}
},
}),
}
})
const resolved = yield* aisdk.model(
model("middleware-test", {
fetch: async (request: Request) => new Response(await request.text()),
}),
)
const response = yield* LLMClient.generate(LLM.request({ model: resolved, prompt: "test" }), {
http: (request, handler) =>
Effect.gen(function* () {
const response = yield* handler(new Request(request, { method: request.method, body: "request-hooked" }))
return new Response(`${yield* Effect.promise(() => response.text())}-response-hooked`)
}),
}).pipe(Effect.provide(client))
expect(response.text).toBe("request-hooked-response-hooked")
}),
)
it.effect("keys language models by package and flattened overlays", () =>
Effect.gen(function* () {
const aisdk = yield* AISDK.Service
+3 -3
View File
@@ -803,7 +803,7 @@ describe("Config", () => {
custom: { command: ["custom-fmt", "$FILE"], extensions: [".foo"] },
},
lsp: { typescript: { disabled: true }, custom: { command: ["custom-lsp"], extensions: [".foo"] } },
media: {
attachments: {
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
},
tool_output: { max_lines: 1000, max_bytes: 32768 },
@@ -890,7 +890,7 @@ describe("Config", () => {
typescript: { disabled: true },
custom: { command: ["custom-lsp"], extensions: [".foo"] },
})
expect(documents[0]?.info.media).toEqual({
expect(documents[0]?.info.attachments).toEqual({
image: { auto_resize: false, max_width: 1200, max_height: 900, max_base64_bytes: 1048576 },
})
expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
@@ -1097,7 +1097,7 @@ describe("Config", () => {
expect(documents[0]?.info.references).toEqual({
docs: { path: "../docs", description: "Use for product documentation", hidden: true },
})
expect(documents[0]?.info.media).toEqual({ image: { auto_resize: false, max_width: 1200 } })
expect(documents[0]?.info.attachments).toEqual({ image: { auto_resize: false, max_width: 1200 } })
expect(documents[0]?.info.providers?.custom).toMatchObject({
settings: { apiKey: "secret" },
models: {
+2 -36
View File
@@ -1,6 +1,5 @@
import { describe, expect } from "bun:test"
import { LLM, Model } from "@opencode-ai/ai"
import { OpenAIChat } from "@opencode-ai/ai/protocols"
import { compileRequest } from "@opencode-ai/ai/route/client"
import { Effect } from "effect"
import { Headers } from "effect/unstable/http"
@@ -18,7 +17,6 @@ interface ModelOptions {
readonly headers?: Info["headers"]
readonly body?: Info["body"]
readonly variants?: Info["variants"]
readonly limit?: Info["limit"]
}
const model = (packageName: string | undefined, options: ModelOptions = {}) =>
@@ -38,7 +36,7 @@ const model = (packageName: string | undefined, options: ModelOptions = {}) =>
cost: [],
status: "active",
enabled: true,
limit: options.limit ?? { context: 100, output: 20 },
limit: { context: 100, output: 20 },
})
describe("ModelResolver", () => {
@@ -46,7 +44,6 @@ describe("ModelResolver", () => {
Effect.gen(function* () {
const catalog = model(Provider.aisdk("@ai-sdk/openai"), {
settings: { baseURL: "https://openai.example/v1" },
limit: { context: 100, input: 80, output: 20 },
})
const resolved = yield* ModelResolver.fromCatalogModel(catalog)
@@ -58,7 +55,7 @@ describe("ModelResolver", () => {
endpoint: { baseURL: "https://openai.example/v1" },
defaults: {
headers: { "x-test": "header" },
limits: { context: 100, input: 80, output: 20 },
limits: { context: 100, output: 20 },
http: { body: { custom_extension: { enabled: true } } },
},
})
@@ -547,37 +544,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("merges mapped OpenRouter headers and body with catalog overlays", () =>
ModelResolver.fromCatalogModel(
model(Provider.aisdk("@openrouter/ai-sdk-provider"), {
settings: {
appName: "OpenCode",
appUrl: "https://opencode.ai",
extraBody: { transforms: ["middle-out"], provider: { sort: "price" } },
},
headers: { "X-OpenRouter-Title": "Custom" },
body: { provider: { only: ["anthropic"] } },
}),
undefined,
{
loadPackage: () =>
Effect.succeed({
model: (modelID, settings) => {
expect(settings.headers).toEqual({
"HTTP-Referer": "https://opencode.ai",
"X-OpenRouter-Title": "Custom",
})
expect(settings.body).toEqual({
transforms: ["middle-out"],
provider: { sort: "price", only: ["anthropic"] },
})
return Model.make({ id: modelID, provider: "openrouter", route: OpenAIChat.route })
},
}),
},
),
)
it.effect("loads supported AISDK catalog packages as native routes", () =>
Effect.gen(function* () {
const google = yield* ModelResolver.fromCatalogModel(
+291
View File
@@ -0,0 +1,291 @@
import { describe, expect } from "bun:test"
import { $ } from "bun"
import fs from "fs/promises"
import path from "path"
import { eq } from "drizzle-orm"
import { Effect, Layer } from "effect"
import { MoveSession } from "@opencode-ai/core/control-plane/move-session"
import { Database } from "@opencode-ai/core/database/database"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Bus } from "@opencode-ai/core/bus"
import { Job } from "@opencode-ai/core/job"
import { Project } from "@opencode-ai/core/project"
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionProjector } from "@opencode-ai/core/session/projector"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// Records the execution serialization a move must perform before relocating.
const executionCalls: string[] = []
const recordingExecution = Layer.succeed(
SessionExecution.Service,
SessionExecution.Service.of({
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
interrupt: (sessionID) => Effect.sync(() => void executionCalls.push(`interrupt:${sessionID}`)),
awaitIdle: (sessionID) => Effect.sync(() => void executionCalls.push(`awaitIdle:${sessionID}`)),
}),
)
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([
MoveSession.node,
Database.node,
Bus.node,
ProjectDirectories.node,
Project.node,
Session.node,
SessionProjector.node,
SessionStore.node,
]),
[[SessionExecution.node, recordingExecution]],
),
)
function abs(input: string) {
return AbsolutePath.make(input)
}
async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.autocrlf false`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await fs.writeFile(path.join(directory, "tracked.txt"), "initial\n")
await $`git add tracked.txt`.cwd(directory).quiet()
await $`git commit -m root`.cwd(directory).quiet()
}
describe("MoveSession", () => {
it.live("moves session changes to another project directory", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-move-destination`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(root.path).quiet())
const moved = abs(yield* Effect.promise(() => fs.realpath(destination)))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move",
directory: source,
title: "move",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
executionCalls.length = 0
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
// The move stops active execution before any relocation side effect.
expect(executionCalls).toEqual([`interrupt:${sessionID}`, `awaitIdle:${sessionID}`])
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("initial\n")
expect(yield* Effect.promise(() => Bun.file(path.join(source, "untracked.txt")).exists())).toBe(false)
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: moved, path: "" })
}),
)
it.live("moves within a checkout without transferring existing changes", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "new\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-nested",
directory: source,
title: "move nested",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
const missing = yield* Session.Service.use((service) =>
service.move({ sessionID, directory: abs("packages") }).pipe(Effect.flip),
)
expect(missing._tag).toBe("Session.DestinationNotFoundError")
yield* Effect.promise(() => fs.mkdir(destination))
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: abs("packages") }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("new\n")
expect(
yield* db
.select({ directory: SessionTable.directory, path: SessionTable.path })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ directory: destination, path: "packages" })
}),
)
it.live("moves a session to another project", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const destination = abs(`${root.path}-other-project`)
yield* Effect.acquireRelease(
Effect.promise(() => fs.mkdir(destination, { recursive: true })),
() => Effect.promise(() => fs.rm(destination, { recursive: true, force: true })),
)
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const destinationProjectID = (yield* Project.Service.use((service) => service.resolve(destination))).id
const sessionID = Session.ID.make("ses_move_project")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-project",
directory: source,
title: "move project",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* Session.Service.use((service) =>
service.move({ sessionID, directory: destination }),
)
expect(
yield* db
.select({ projectID: SessionTable.project_id, directory: SessionTable.directory })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get(),
).toEqual({ projectID: destinationProjectID, directory: destination })
}),
)
it.live("moves nested session changes without cleaning unrelated files", () =>
Effect.gen(function* () {
const root = yield* Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
)
yield* Effect.promise(() => initRepo(root.path))
const source = abs(yield* Effect.promise(() => fs.realpath(root.path)))
const sourceDirectory = abs(path.join(source, "packages"))
yield* Effect.promise(() => fs.mkdir(sourceDirectory))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "initial\n"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "initial\n"))
yield* Effect.promise(() => $`git add packages/tracked.txt packages/staged.txt`.cwd(source).quiet())
yield* Effect.promise(() => $`git commit -m packages`.cwd(source).quiet())
const destination = abs(`${root.path}-move-nested-destination`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(destination, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${destination} HEAD`.cwd(source).quiet())
const moved = abs(path.join(yield* Effect.promise(() => fs.realpath(destination)), "packages"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "tracked.txt"), "changed\n"))
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "staged.txt"), "staged\n"))
yield* Effect.promise(() => $`git add packages/staged.txt`.cwd(source).quiet())
yield* Effect.promise(() => fs.writeFile(path.join(sourceDirectory, "untracked.txt"), "new\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "tracked.txt"), "unrelated\n"))
yield* Effect.promise(() => fs.writeFile(path.join(source, "untracked.txt"), "unrelated\n"))
const projectID = (yield* Project.Service.use((service) => service.resolve(source))).id
const sessionID = Session.ID.make("ses_move_nested_checkout")
const { db } = yield* Database.Service
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "move-nested-checkout",
directory: sourceDirectory,
title: "move nested checkout",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
.pipe(Effect.orDie)
yield* MoveSession.Service.use((service) =>
service.moveSession({ sessionID, destination: { directory: moved }, moveChanges: true }),
)
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "tracked.txt"), "utf8"))).toBe("changed\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "staged.txt"), "utf8"))).toBe("staged\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(moved, "untracked.txt"), "utf8"))).toBe("new\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "tracked.txt"), "utf8"))).toBe(
"initial\n",
)
expect(yield* Effect.promise(() => Bun.file(path.join(sourceDirectory, "untracked.txt")).exists())).toBe(false)
expect(yield* Effect.promise(() => fs.readFile(path.join(sourceDirectory, "staged.txt"), "utf8"))).toBe(
"staged\n",
)
expect(yield* Effect.promise(() => $`git status --porcelain -- packages/staged.txt`.cwd(source).text())).toBe(
"M packages/staged.txt\n",
)
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "tracked.txt"), "utf8"))).toBe("unrelated\n")
expect(yield* Effect.promise(() => fs.readFile(path.join(source, "untracked.txt"), "utf8"))).toBe("unrelated\n")
}),
)
})
-2
View File
@@ -106,10 +106,8 @@ export function host(overrides: Overrides = {}): Plugin.Context {
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
rename: overrides.session?.rename ?? (() => Effect.die("unused session.rename")),
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
wait: overrides.session?.wait ?? (() => Effect.die("unused session.wait")),
},
}
}
-33
View File
@@ -221,39 +221,6 @@ describe("fromPromise", () => {
}),
)
it.effect("adapts promise session HTTP hooks", () =>
Effect.gen(function* () {
const plugin = yield* Plugin.Service
const hooks = yield* PluginHooks.Service
const host = yield* PluginHost.make(plugin)
yield* PluginPromise.fromPromise(
define({
id: "promise-session-http",
setup: async (ctx) => {
await ctx.session.hook("http", (event) => {
const request = event.request
event.request = async (input) => {
const response = await request(new Request(input, { headers: { "x-hook": "promise" } }))
return new Response(`${await response.text()}-response`)
}
})
},
}),
).effect(host)
const event: SessionHooks["http"] = {
sessionID: Session.ID.make("ses_promise_session_http"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make("model") }),
request: (input) => Effect.succeed(new Response(input.headers.get("x-hook") ?? "missing")),
}
yield* hooks.trigger("session", "http", event)
const response = yield* event.request(new Request("https://provider.test"))
expect(yield* Effect.promise(() => response.text())).toBe("promise-response")
}),
)
it.effect("disposes a hook registration on request", () =>
Effect.gen(function* () {
const agents = yield* Agent.Service
@@ -29,21 +29,6 @@ function required<T>(value: T | undefined): T {
return value
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
request: (input) => {
const headers = new Headers(input.headers)
headers.set("x-seen-url", input.url)
return Effect.succeed(new Response(null, { headers }))
},
})
const response = yield* event.request(new Request(url, { method: "POST", body: "{}" }))
return { url: response.headers.get("x-seen-url"), headers: Object.fromEntries(response.headers.entries()) }
})
describe("OpenAIPlugin", () => {
it.effect("registers browser and headless ChatGPT OAuth methods", () =>
Effect.gen(function* () {
@@ -89,9 +74,6 @@ describe("OpenAIPlugin", () => {
]
})
catalog.model.update(item.id, Model.ID.make("gpt-5.5-pro"), () => {})
catalog.model.update(item.id, Model.ID.make("gpt-5.4"), (model) => {
model.limit = { context: 1_050_000, input: 922_000, output: 64_000 }
})
catalog.model.update(item.id, Model.ID.make("gpt-5.4-pro"), (model) => {
model.modelID = Model.ID.make("gpt-5.4")
model.body = { reasoning: { mode: "pro" } }
@@ -115,9 +97,33 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const custom = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.make("custom-openai"), id: Model.ID.make("gpt-5.5") }),
url: "https://custom.example/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const proxy = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://proxy.example/v1/responses?region=us",
method: "POST",
headers: {},
body: "{}",
})
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
@@ -125,13 +131,13 @@ describe("OpenAIPlugin", () => {
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).not.toHaveProperty("originator")
expect(custom.headers).toEqual({})
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
expect(eligible.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(eligible.limit).toEqual({ context: 400_000, input: 272_000, output: 128_000 })
expect(eligible.enabled).toBe(true)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5-pro"))).enabled).toBe(
false,
@@ -139,15 +145,10 @@ describe("OpenAIPlugin", () => {
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4-pro"))).enabled).toBe(
false,
)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.4"))).limit).toEqual({
context: 272_000,
input: 272_000,
output: 64_000,
})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6"))).enabled).toBe(false)
const gpt56 = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.6-sol")))
expect(gpt56.enabled).toBe(true)
expect(gpt56.limit).toEqual({ context: 272_000, input: 272_000, output: 128_000 })
expect(gpt56.limit).toEqual({ context: 500_000, input: 372_000, output: 128_000 })
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(false)
}),
)
@@ -175,13 +176,21 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const request = yield* (yield* PluginHooks.Service).trigger("session", "request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID: Provider.ID.openai, id: Model.ID.make("gpt-5.5") }),
url: "https://api.openai.com/v1/responses",
method: "POST",
headers: {},
body: "{}",
})
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).not.toHaveProperty("originator")
expect(request.headers).toEqual({})
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
+1 -49
View File
@@ -19,11 +19,9 @@ import { Session } from "@opencode-ai/core/session"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { App } from "@opencode-ai/core/app"
import { Agent } from "@opencode-ai/core/agent"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Money } from "@opencode-ai/schema/money"
import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
import { asc, eq } from "drizzle-orm"
import { testEffect } from "./lib/effect"
@@ -132,52 +130,6 @@ test("compaction prompt requires the checkpoint headings in order", () => {
expect(prompt).toContain("Keep every section, even when empty.")
})
it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
Effect.gen(function* () {
const compaction = yield* SessionCompaction.Service
const session = Session.Info.make({
id: Session.ID.make("ses_input_limit"),
projectID: Project.ID.global,
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
})
const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({
session,
model: Model.make({
id: "test-model",
provider: "test-provider",
route: OpenAIChat.route.with({ limits }),
}),
cost: [],
messages: [
Schema.decodeUnknownSync(SessionMessage.Assistant)({
id: SessionMessage.ID.make("msg_assistant"),
type: "assistant",
agent: Agent.defaultID,
model: { id: "test-model", providerID: "test-provider" },
content: [],
tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, completed: 0 },
}),
],
})
const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
expect(compaction.required(input(251_999, inputLimited))).toBe(false)
expect(compaction.required(input(252_000, inputLimited))).toBe(true)
const contextLimited = { context: 100_000, output: 10_000 }
expect(compaction.required(input(79_999, contextLimited))).toBe(false)
expect(compaction.required(input(80_000, contextLimited))).toBe(true)
const outputLimited = { context: 100_000, output: 30_000 }
expect(compaction.required(input(69_999, outputLimited))).toBe(false)
expect(compaction.required(input(70_000, outputLimited))).toBe(true)
}),
)
it.effect("manual compaction summarizes short context instead of no-op", () =>
Effect.gen(function* () {
requests = []
-20
View File
@@ -14,9 +14,6 @@ import {
TransportReason,
UnknownProviderReason,
ToolFailure,
HttpContext,
HttpRequestDetails,
HttpResponseDetails,
} from "@opencode-ai/ai"
import { Permission } from "@opencode-ai/core/permission"
import { Tool } from "@opencode-ai/schema/tool"
@@ -74,23 +71,6 @@ describe("toSessionError", () => {
})
})
test("preserves provider HTTP status", () => {
const http = new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: "https://example.com", headers: {} }),
response: new HttpResponseDetails({ status: 413, headers: {} }),
})
expect(toSessionError(llm(new InvalidRequestReason({ message: "too large", http })))).toEqual({
type: "provider.invalid-request",
message: "too large",
status: 413,
})
expect(toSessionError(llm(new ProviderInternalReason({ message: "bad gateway", status: 502 })))).toEqual({
type: "provider.internal",
message: "bad gateway",
status: 502,
})
})
test("retries only rate limits, provider-internal failures, and transport failures", () => {
const eligible = [
llm(new RateLimitReason({ message: "rate" })),
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Message, ToolResultPart } from "@opencode-ai/ai"
import { boundImages, unsupportedParts } from "@opencode-ai/core/session/model-request"
import { unsupportedParts } from "@opencode-ai/core/session/model-request"
const capabilities = (input: string[]) => ({ tools: true, input, output: ["text"] })
@@ -62,51 +62,3 @@ describe("SessionModelRequest.unsupportedParts", () => {
expect(unsupportedParts([message], capabilities(["text", "image"]))[0]?.content).toEqual(message.content)
})
})
describe("SessionModelRequest.boundImages", () => {
test("preserves images below the trigger", () => {
const messages = [Message.user({ type: "media", mediaType: "image/png", data: "aGVsbG8=" })]
expect(boundImages(messages)).toBe(messages)
})
test("replaces oldest images until the retained payload reaches the target", () => {
const image = "a".repeat(9 * 1024 * 1024)
const messages = [
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "first.png" }),
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "second.png" }),
Message.user({ type: "media", mediaType: "image/png", data: image, filename: "third.png" }),
]
const result = boundImages(messages)
expect(result[0]?.content[0]).toMatchObject({ type: "text" })
expect(result[1]?.content[0]).toMatchObject({ type: "text" })
expect(result[2]?.content[0]).toMatchObject({ type: "media", filename: "third.png" })
})
test("replaces images nested in tool results", () => {
const image = "a".repeat(13 * 1024 * 1024)
const result = boundImages([
Message.tool(
ToolResultPart.make({
id: "call_1",
name: "read",
result: {
type: "content",
value: [
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "first.png" },
{ type: "file", uri: `data:image/png;base64,${image}`, mime: "image/png", name: "second.png" },
],
},
}),
),
])
expect(result[0]?.content[0]).toMatchObject({
type: "tool-result",
result: {
type: "content",
value: [{ type: "text" }, { type: "file", name: "second.png" }],
},
})
})
})
+2 -34
View File
@@ -26,8 +26,8 @@ import { SessionPendingTable, SessionMessageTable, SessionTable } from "@opencod
import { SessionStore } from "@opencode-ai/core/session/store"
import { LocationServiceMap } from "@opencode-ai/core/location-service-map"
import type { LocationServices } from "@opencode-ai/core/location-services"
import { Image } from "@opencode-ai/core/image"
import { testEffect } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
const executionCalls: Session.ID[] = []
const interruptCalls: Session.ID[] = []
@@ -58,10 +58,7 @@ const locations = Layer.effect(
() =>
// Attachment admission only needs the location-scoped Image service.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.mock(Image.Service, {
normalize: (_resource, content) =>
Effect.succeed(content.content.length > 5 * 1024 * 1024 ? { ...content, content: "AA==" } : content),
}) as unknown as Layer.Layer<LocationServices>,
imagePassthrough as unknown as Layer.Layer<LocationServices>,
),
)
const it = testEffect(
@@ -389,35 +386,6 @@ describe("Session.prompt", () => {
}),
)
it.effect("normalizes large image content before validating persisted Base64", () =>
Effect.gen(function* () {
yield* setup
const session = yield* Session.Service
const pixel = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
)
const bytes = Buffer.concat([pixel, Buffer.alloc(4_323_030 - pixel.length)])
const data = bytes.toString("base64")
expect(data).toHaveLength(5_764_040)
const message = yield* session.prompt({
sessionID,
text: "Inspect this image",
files: [{ uri: `data:image/png;base64,${data}` }],
resume: false,
})
expect(message.data.files).toEqual([
{
data: "AA==",
mime: "image/png",
source: { type: "inline" },
},
])
}),
)
it.effect("sniffs data URL content instead of trusting its declared MIME", () =>
Effect.gen(function* () {
yield* setup
+6 -6
View File
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect } from "bun:test"
import path from "path"
import { Effect, Exit, Layer, PlatformError, Stream } from "effect"
import { Config } from "@opencode-ai/core/config"
import { ConfigMedia } from "@opencode-ai/core/config/media"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { FileSystem } from "@opencode-ai/core/filesystem"
@@ -432,8 +432,8 @@ describe("ReadTool", () => {
new Config.Document({
type: "document",
info: new Config.Info({
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ auto_resize: false, max_width: 4 }),
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ auto_resize: false, max_width: 4 }),
}),
}),
}),
@@ -475,7 +475,7 @@ describe("ReadTool", () => {
new Config.Document({
type: "document",
info: new Config.Info({
media: new ConfigMedia.Info({ image: new ConfigMedia.Image({ max_width: 4 }) }),
attachments: new ConfigAttachments.Info({ image: new ConfigAttachments.Image({ max_width: 4 }) }),
}),
}),
])
@@ -514,8 +514,8 @@ describe("ReadTool", () => {
new Config.Document({
type: "document",
info: new Config.Info({
media: new ConfigMedia.Info({
image: new ConfigMedia.Image({ max_base64_bytes: 1 }),
attachments: new ConfigAttachments.Info({
image: new ConfigAttachments.Image({ max_base64_bytes: 1 }),
}),
}),
}),
-1
View File
@@ -16,7 +16,6 @@
"@opencode-ai/core": "workspace:*",
"@opencode-ai/session-ui": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
"aws4fetch": "^1.0.20",
"@pierre/diffs": "catalog:",
"@solidjs/router": "catalog:",
@@ -4,7 +4,6 @@ import { SessionReview } from "@opencode-ai/session-ui/session-review"
import { DataProvider } from "@opencode-ai/session-ui/context"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { WorkerPoolProvider } from "@opencode-ai/ui/context/worker-pool"
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
import { createAsync, query, useParams } from "@solidjs/router"
import { createMemo, createSignal, ErrorBoundary, For, Match, Show, Switch } from "solid-js"
import { Share } from "~/core/share"
@@ -159,7 +158,11 @@ export default function () {
const match = createMemo(() => Binary.search(data().session, data().sessionID, (s) => s.id))
if (!match().found) throw new Error(`Session ${data().sessionID} not found`)
const info = createMemo(() => data().session[match().index])
const title = createMemo(() => withTimestampedFallback(info()))
const title = createMemo(
() =>
info().title ??
`${info().parentID ? "Child" : "New"} session - ${new Date(info().time.created).toISOString()}`,
)
const ogImage = createMemo(() => {
const models = new Set<string>()
const messages = data().message[data().sessionID] ?? []
+5 -5
View File
@@ -1,9 +1,10 @@
import type { SessionApi } from "@opencode-ai/client/effect/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
import type { Effect, JsonSchema } from "effect"
import type { JsonSchema } from "effect"
import type { Hooks } from "./registration.js"
export interface SessionContext {
@@ -15,21 +16,20 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Effect.Effect<Response, Error>
}
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly request: SessionRequest
}
export type SessionDomain = Pick<
SessionApi<unknown>,
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt" | "rename" | "wait"
"create" | "get" | "prompt" | "generate" | "command" | "synthetic" | "interrupt"
> & {
readonly hook: Hooks<SessionHooks>
}
+3 -3
View File
@@ -1,5 +1,6 @@
import type { SessionApi } from "@opencode-ai/client/promise/api"
import type { Message, SystemPart } from "@opencode-ai/ai"
import type { HttpRequest } from "@opencode-ai/ai/route"
import type { Agent } from "@opencode-ai/schema/agent"
import type { Model } from "@opencode-ai/schema/model"
import type { Session } from "@opencode-ai/schema/session"
@@ -15,16 +16,15 @@ export interface SessionContext {
tools: Record<string, { description: string; input: JsonSchema.JsonSchema }>
}
export interface SessionHttp {
export interface SessionRequest extends HttpRequest {
readonly sessionID: Session.ID
readonly agent: Agent.ID
readonly model: Model.Ref
request: (input: Request) => Promise<Response>
}
export interface SessionHooks {
readonly context: SessionContext
readonly http: SessionHttp
readonly request: SessionRequest
}
export type SessionDomain = Pick<
-18
View File
@@ -26,27 +26,12 @@ import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
export interface Storage {
/**
* Durable JSON state: persisted to disk, survives hot reloads and TUI
* restarts, and stays live-synced across running TUI instances.
*/
store<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-memory state: survives plugin hot reloads (old and new
* generations share the same live store) and is gone when the TUI exits.
* Updates are synchronous and values need not be JSON-serializable.
*/
memory<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
}
interface LocationCollection<Value> {
@@ -151,9 +136,6 @@ export interface SlotMap {
readonly sessionID?: string
readonly mode: "normal" | "shell"
}
readonly "session.composer.top": {
readonly sessionID: string
}
readonly "sidebar.content": {
readonly sessionID: string
}
-1
View File
@@ -149,7 +149,6 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
HttpApiEndpoint.post("session.create", "/api/session", {
payload: Schema.Struct({
id: Session.ID.pipe(Schema.optional),
title: Schema.String.pipe(Schema.optional),
agent: Agent.ID.pipe(Schema.optional),
model: Model.Ref.pipe(Schema.optional),
location: Location.Ref.pipe(Schema.optional),
-2
View File
@@ -1,11 +1,9 @@
export * as SessionError from "./session-error.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
export interface Error extends Schema.Schema.Type<typeof Error> {}
export const Error = Schema.Struct({
type: Schema.String,
message: Schema.String,
status: Schema.Int.check(Schema.isBetween({ minimum: 100, maximum: 599 })).pipe(optional),
}).annotate({ identifier: "Session.StructuredError" })
-1
View File
@@ -77,7 +77,6 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
data: yield* session
.create({
id: ctx.payload.id,
title: ctx.payload.title,
agent: ctx.payload.agent,
model: ctx.payload.model,
location: ctx.payload.location ?? { directory: AbsolutePath.make(process.cwd()) },
@@ -3,6 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -140,44 +141,46 @@ export function PromptInputV2(props: PromptInputV2Props) {
</Show>
<div class="relative min-h-[60px]">
<div
ref={(element) => {
editor = element
props.controller.setEditor(element)
renderPromptInputV2Editor(element, props.controller.parts())
}}
data-component="prompt-input"
role="textbox"
aria-multiline="true"
aria-label="Prompt"
contenteditable={!props.disabled && !props.readOnly}
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
autocorrect={state.mode === "normal" ? "on" : "off"}
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block min-h-[60px] max-h-[180px] w-full overflow-y-auto whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
onInput={(event) => {
const cursor = promptInputV2Cursor(event.currentTarget)
const prompt = parsePromptInputV2Editor(event.currentTarget)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit()
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={props.controller.onPaste}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
<ScrollView class="min-h-[60px] max-h-[180px]">
<div
ref={(element) => {
editor = element
props.controller.setEditor(element)
renderPromptInputV2Editor(element, props.controller.parts())
}}
data-component="prompt-input"
role="textbox"
aria-multiline="true"
aria-label="Prompt"
contenteditable={!props.disabled && !props.readOnly}
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
autocorrect={state.mode === "normal" ? "on" : "off"}
spellcheck={state.mode === "normal"}
// @ts-expect-error
autocomplete="off"
class="relative z-10 block min-h-[60px] w-full whitespace-pre-wrap bg-transparent px-4 pt-4 pb-2 text-[13px] font-[440] leading-5 text-v2-text-text-base focus:outline-none empty:before:content-['\200B'] [&_[data-mention=file]]:text-syntax-property [&_[data-mention=agent]]:text-syntax-type [&_[data-mention=reference]]:text-syntax-keyword"
classList={{ "font-mono!": state.mode === "shell", "opacity-50": props.disabled }}
onInput={(event) => {
const cursor = promptInputV2Cursor(event.currentTarget)
const prompt = parsePromptInputV2Editor(event.currentTarget)
const images = props.controller.parts().filter((part) => part.type === "image")
localInput = true
props.controller.onInput(prompt.map((part) => part.content).join(""), [...prompt, ...images], cursor)
}}
onKeyDown={(event) => {
if (props.controller.onKeyDown(event)) return
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault()
if (event.repeat) return
props.controller.submit()
}
}}
onKeyUp={updateCursor}
onPointerUp={updateCursor}
onPaste={props.controller.onPaste}
onFocus={() => props.controller.dispatch({ type: "focus.editor" })}
/>
</ScrollView>
<Show when={!props.controller.value()}>
<div
class="pointer-events-none absolute inset-x-0 top-0 px-4 pt-4 text-[13px] font-[440] leading-5 text-v2-text-text-faint"
-1
View File
@@ -45,7 +45,6 @@ export type {
StatefulColor,
} from "./types.js"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME } from "./defaults.js"
export { expandTheme } from "./expand.js"
export { migrateV1 } from "./v1-migrate.js"
export { resolveTheme, resolveThemeDocument, themeDecodeError } from "./resolve.js"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select.js"
+1
View File
@@ -93,6 +93,7 @@
"@opentui/solid": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"clipboardy": "4.0.0",
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"get-east-asian-width": "catalog:",
+7 -11
View File
@@ -72,18 +72,17 @@ import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
import { Session } from "./routes/session"
import { PromptHistoryProvider } from "./prompt/history"
import { FrecencyProvider } from "./prompt/frecency"
import { PromptStashProvider } from "./prompt/stash"
import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash"
import { Toast, ToastProvider, useToast } from "./ui/toast"
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
import { isDefaultTitle } from "./util/session"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { Config, ConfigProvider, useConfig } from "./config"
import { PluginProvider, usePlugin, type PackageResolver } from "./plugin/context"
import { PluginRoute, PluginSlot } from "./plugin/render"
import { PluginProvider, PluginRoute, PluginSlot, usePlugin, type PackageResolver } from "./plugin/context"
import { CommandPaletteDialog } from "./component/command-palette"
import { COMMAND_PALETTE_COMMAND, Keymap, type KeymapCommand } from "./context/keymap"
@@ -538,7 +537,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (route.data.type === "session") {
const title = session?.title
if (!title || isFallbackTitle(title)) {
if (!title || isDefaultTitle(title)) {
renderer.setTerminalTitle("OpenCode")
return
}
@@ -647,10 +646,7 @@ function App(props: { pair?: DialogPairCredentials }) {
run: () => {
route.navigate({
type: "home",
location:
route.data.type === "session"
? (data.session.get(route.data.sessionID)?.location ?? location.ref)
: undefined,
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
})
dialog.clear()
},
+3
View File
@@ -5,6 +5,7 @@ import type {
TuiAttentionNotifyResult,
TuiAttentionNotifySkipReason,
TuiAttentionWhen,
TuiKV,
TuiAttentionSoundName,
TuiAttentionSoundPack,
TuiAttentionSoundPackInfo,
@@ -114,6 +115,8 @@ export function createTuiAttention(input: {
renderer: AttentionRenderer
config: Pick<Config.Resolved, "attention">
update?: Config.Interface["update"]
/** @deprecated Ignored. Sound-pack persistence uses CLI config. */
kv?: TuiKV
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
}): TuiAttentionHost {
let focus: FocusState = "unknown"
+5 -1
View File
@@ -1,4 +1,4 @@
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound } from "@opentui/core"
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
import { readFile } from "node:fs/promises"
let audio: Audio | null | undefined
@@ -42,6 +42,10 @@ export function play(sound: AudioSound, options?: AudioPlayOptions) {
return current.play(sound, options)
}
export function stopVoice(voice: AudioVoice) {
return audio?.stopVoice(voice) ?? false
}
export function dispose() {
audio?.dispose()
audio = undefined
+10 -1
View File
@@ -43,6 +43,15 @@ export const settings: Setting[] = [
labels: ["off", "on"],
keywords: ["motion", "effects"],
},
{
title: "Onboarding",
category: "Appearance",
path: ["hints", "onboarding"],
default: true,
values: [false, true],
labels: ["off", "on"],
keywords: ["hints", "getting started", "guidance"],
},
{
title: "Sidebar",
category: "Session",
@@ -96,7 +105,7 @@ export const settings: Setting[] = [
title: "Scope",
category: "Tabs",
path: ["tabs", "scope"],
default: "global",
default: "cwd",
values: ["cwd", "global"],
labels: ["current directory", "global"],
},
+4 -10
View File
@@ -63,15 +63,15 @@ export function DialogModel(props: { providerID?: string }) {
.filter((model) => (props.providerID ? model.providerID === props.providerID : true))
.map((model) => {
const provider = providers().get(model.providerID)
const favorite = favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
return {
value: { providerID: model.providerID, modelID: model.id },
providerID: model.providerID,
providerName: provider?.name ?? model.providerID,
title: model.name,
releaseDate: model.time.released,
favorite,
description: favorite ? "(Favorite)" : undefined,
description: favorites.some((item) => item.providerID === model.providerID && item.modelID === model.id)
? "(Favorite)"
: undefined,
category: connected() ? (provider?.name ?? model.providerID) : undefined,
footer: free(model) ? "Free" : undefined,
onSelect() {
@@ -96,9 +96,7 @@ export function DialogModel(props: { providerID?: string }) {
)
if (needle) {
return prioritizeFavorites(
fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj),
)
return fuzzysort.go(needle, modelOptions, { keys: ["title", "category"] }).map((item) => item.obj)
}
return [...favoriteOptions, ...recentOptions, ...modelOptions]
@@ -162,10 +160,6 @@ export function DialogModel(props: { providerID?: string }) {
)
}
export function prioritizeFavorites<T extends { favorite: boolean }>(options: T[]) {
return options.toSorted((a, b) => Number(b.favorite) - Number(a.favorite))
}
export function sortModelOptions<
T extends { providerID?: string; providerName?: string; releaseDate: string | number; title: string },
>(options: T[]) {

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