Compare commits

..
Author SHA1 Message Date
Adam c7106c1628 feat(core): enforce managed provider policies 2026-09-10 18:41:04 -05:00
128 changed files with 2733 additions and 5200 deletions
-4
View File
@@ -165,10 +165,6 @@ Native chronological system messages are route/model-specific. Open Responses lo
The wrapped-user fallback preserves ordering while visibly lowering authority. Never silently pass a raw chronological `role: "system"` through a route that might reject it. Do not insert raw retrieved documents, tool output, or web content into privileged chronological system updates; keep untrusted content in ordinary user/tool channels.
### Effort Updates
`Message.effort({ effort, previous })` is a chronological "reasoning effort changed here" marker (`undefined` means the model default). Changing a top-level effort invalidates the whole provider prompt cache, so protocols with a native per-message update (`Protocol.supportsEffortUpdates`) keep the top-level effort at the first marker's `previous` and lower each marker in place: Anthropic Messages emits an empty `role: "system"` message with `output_config.effort` plus the `mid-conversation-output-config-2026-07-01` beta, and OpenAI Responses emits `configuration_update` items. `applyEffortUpdates` runs in `prepareRequest` and strips the markers for every other route, so a protocol without support keeps today's plain top-level behaviour. When the last marker disagrees with the effort the request asks for (reverted or forked history), `resolveEffortUpdates` strips the markers and falls back to a plain top-level change.
### Tools
Tool loops are represented in common messages and events:
+2 -9
View File
@@ -12,7 +12,6 @@
// count against the four-breakpoint budget; auto only fills remaining slots.
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options.js"
import { LLMRequest, Message, ToolDefinition, type ContentPart, type ToolEntry } from "./schema/messages.js"
import { effortUpdate } from "./effort-updates.js"
const AUTO: CachePolicyObject = {
tools: true,
@@ -122,15 +121,9 @@ const markMessages = (
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
if (strategy === "latest-assistant")
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
let start = messages.length
let remaining = strategy.tail
while (remaining > 0 && start > 0) {
start -= 1
if (effortUpdate(messages[start]!) === undefined) remaining -= 1
}
const start = Math.max(0, messages.length - strategy.tail)
let next = messages
for (let i = start; i < messages.length; i++)
if (effortUpdate(messages[i]!) === undefined) next = markMessageAt(next, i, hint, budget)
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
return next
}
-27
View File
@@ -1,27 +0,0 @@
// A top-level reasoning effort change invalidates the whole provider prompt cache, so a
// mid-conversation switch travels as a `Message.effort(...)` marker: protocols with a native
// per-message update freeze the top-level effort and lower the markers; every other route strips them.
import { LLMRequest, type EffortPart, type Message } from "./schema/messages.js"
export const effortUpdate = (message: Message): EffortPart | undefined => {
if (message.role !== "system" || message.content.length !== 1) return undefined
const part = message.content[0]
return part.type === "effort" ? part : undefined
}
export const stripEffortUpdates = (request: LLMRequest) => {
const messages = request.messages.filter((message) => effortUpdate(message) === undefined)
return messages.length === request.messages.length ? request : LLMRequest.update(request, { messages })
}
export const applyEffortUpdates = (request: LLMRequest): LLMRequest =>
request.model.route.supportsEffortUpdates?.(request) ? request : stripEffortUpdates(request)
// The markers must end at `current`: `revert.ts` never touches `session.model` and forks may select
// another variant, so on disagreement fall back to a plain top-level change instead of misreporting effort.
export const resolveEffortUpdates = (request: LLMRequest, current: string | undefined) => {
const updates = request.messages.flatMap((message) => effortUpdate(message) ?? [])
if (updates.length === 0) return { request, effort: current }
if (updates.at(-1)?.effort !== current) return { request: stripEffortUpdates(request), effort: current }
return { request, effort: updates[0]?.previous }
}
+18 -50
View File
@@ -27,7 +27,6 @@ import {
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -37,7 +36,6 @@ const ADAPTER = "anthropic-messages"
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
export const PATH = "/messages"
export const DEFAULT_MAX_TOKENS = 32_000
const DEFAULT_EFFORT = "high"
const SSE_EVENTS = new Set([
"message",
@@ -288,11 +286,7 @@ type AnthropicToolResultBlock = Schema.Schema.Type<typeof AnthropicToolResultBlo
const AnthropicMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(AnthropicUserBlock) }),
Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(AnthropicAssistantBlock) }),
Schema.Struct({
role: Schema.Literal("system"),
content: Schema.Array(AnthropicTextBlock),
output_config: Schema.optional(Schema.Struct({ effort: Schema.String })),
}),
Schema.Struct({ role: Schema.Literal("system"), content: Schema.Array(AnthropicTextBlock) }),
]).pipe(Schema.toTaggedUnion("role"))
type AnthropicMessage = Schema.Schema.Type<typeof AnthropicMessage>
@@ -883,12 +877,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
for (const [index, message] of request.messages.entries()) {
if (message.role === "system") {
const update = effortUpdate(message)
if (update) {
// Accepted at any position, so the text-update placement rules do not apply.
messages.push({ role: "system", content: [], output_config: { effort: update.effort ?? DEFAULT_EFFORT } })
continue
}
if (splitsLocalToolResults(request.messages, index))
return yield* invalid("Anthropic Messages system updates cannot split a local tool call from its tool result")
if (supportsNativeSystemUpdates(request) && canUseNativeSystemUpdate(request, index)) {
@@ -1046,11 +1034,18 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
ProviderShared.isRecord(rawOutputConfig) && ProviderShared.isRecord(rawOutputConfig.format)
? (rawOutputConfig.format as { type: "json_schema"; schema: Record<string, unknown> })
: undefined
const output_config =
outputConfigEffort === undefined && outputConfigFormat === undefined
? undefined
: {
...(outputConfigEffort === undefined ? {} : { effort: outputConfigEffort }),
...(outputConfigFormat === undefined ? {} : { format: outputConfigFormat }),
}
const thinking = yield* resolveThinking(input?.thinking)
return {
thinking: applyThinkingBindingDefault(request.model, thinking),
effort: outputConfigEffort,
format: outputConfigFormat,
output_config,
service_tier,
metadata,
container,
@@ -1059,30 +1054,15 @@ const resolveOptions = Effect.fn("AnthropicMessages.resolveOptions")(function* (
}
})
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const claudeVersion = (id: string) => {
const match = /(?:^|[./])claude-(?<family>[a-z]+)-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/.exec(
id.toLowerCase(),
)?.groups
if (!match) return undefined
return { family: match.family, major: Number(match.major), minor: Number(match.minor ?? 0) }
}
const supportsThinkingBlockBinding = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsThinkingBlockBinding
if (override !== undefined) return override
const version = claudeVersion(model.id)
return version !== undefined && (version.major > 5 || (version.major === 5 && version.minor >= 1))
}
const supportsEffortUpdates = (model: LLMRequest["model"]) => {
const override = model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
const version = claudeVersion(model.id)
if (version === undefined) return false
if (version.family === "opus") return version.major >= 5
if (version.family !== "fable" && version.family !== "mythos") return false
return version.major > 5 || (version.major === 5 && version.minor >= 1)
// Accept gateway namespaces and Vertex suffixes without treating a snapshot date as a minor version.
const version = /(?:^|[./])claude-[a-z]+-(?<major>\d+)(?:[.-](?<minor>\d{1,2}))?(?:$|[-:@])/i.exec(model.id)?.groups
if (!version) return false
const major = Number(version.major)
const minor = Number(version.minor ?? 0)
return major > 5 || (major === 5 && minor >= 1)
}
const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: AnthropicThinking | undefined) => {
@@ -1124,15 +1104,13 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = yield* resolveOptions(request)
const updates = resolveEffortUpdates(request, options.effort)
const generation = request.generation
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
// messages. Tools live highest in the cache hierarchy, so when callers
// over-mark we keep their tool hints and shed the message-tail ones first.
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
const flattened = ProviderShared.flattenToolRequest(updates.request)
const flattened = ProviderShared.flattenToolRequest(request)
const tools =
flattened.tools.length === 0
? undefined
@@ -1160,13 +1138,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
`Anthropic Messages: dropped ${breakpoints.dropped} cache breakpoint(s); the API allows at most ${ANTHROPIC_BREAKPOINT_CAP} per request.`,
)
}
const output_config =
updates.effort === undefined && options.format === undefined
? undefined
: {
...(updates.effort === undefined ? {} : { effort: updates.effort }),
...(options.format === undefined ? {} : { format: options.format }),
}
const options = yield* resolveOptions(request)
const body = {
model: request.model.id,
system,
@@ -1180,7 +1152,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
top_k: generation?.topK,
stop_sequences: generation?.stop,
thinking: options.thinking,
output_config,
output_config: options.output_config,
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
cache_control: options.cache_control,
container: options.container,
@@ -1705,7 +1677,6 @@ export const protocol = Protocol.make({
}),
step,
},
supportsEffortUpdates: (request) => supportsEffortUpdates(request.model),
})
export const transport = <
@@ -1744,9 +1715,6 @@ function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "con
)
if (requestsCompaction || replaysCompaction) betas.push("compact-2026-01-12")
if (body.messages.some((message) => message.role === "system" && message.output_config !== undefined))
betas.push("mid-conversation-output-config-2026-07-01")
const thinking = body.thinking
if (thinking && thinking.type !== "disabled" && thinking.block_binding)
betas.push("thinking-binding-controls-2026-08-01")
+2 -20
View File
@@ -20,7 +20,6 @@ import {
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate } from "../effort-updates.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -165,13 +164,6 @@ export const CompactionItem = Schema.Struct({
encrypted_content: Schema.String,
})
// Kept out of the baseline `InputItem` union: only the OpenAI extension accepts it.
export const ConfigurationUpdate = Schema.Struct({
type: Schema.Literal("configuration_update"),
reasoning: Schema.Struct({ effort: OpenResponsesOptions.ReasoningEffort }),
})
type ConfigurationUpdate = Schema.Schema.Type<typeof ConfigurationUpdate>
export const InputItem = Schema.Union([
CompactionItem,
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
@@ -216,7 +208,6 @@ export type HostedToolReplayItem = {
type LoweredInputItem =
| OpenResponsesInputItem
| HostedToolReplayItem
| ConfigurationUpdate
| {
readonly type: "message"
readonly id?: string
@@ -643,8 +634,6 @@ const lowerToolResultOutput = Effect.fnUntraced(function* (
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, adapter))
})
const DEFAULT_EFFORT = "medium"
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
request: LLMRequest,
adapter: ProviderAdapter,
@@ -657,14 +646,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
)(message.providerMetadata?.[providerMetadataKey])
if (message.role === "system") {
const update = effortUpdate(message)
if (update) {
// Consecutive updates are rejected, so a newer one replaces its predecessor.
const last = input.at(-1)
if (last !== undefined && "type" in last && last.type === "configuration_update") input.pop()
input.push({ type: "configuration_update", reasoning: { effort: update.effort ?? DEFAULT_EFFORT } })
continue
}
input.push({
role: "developer",
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(adapter.name, message)),
@@ -808,7 +789,8 @@ export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(fu
}
})
export const lowerGeneration = (request: LLMRequest, options = OpenResponsesOptions.resolve(request)) => {
export const lowerGeneration = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const generation = request.generation
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
+4 -23
View File
@@ -6,9 +6,7 @@ import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
import { resolveEffortUpdates } from "../effort-updates.js"
import { OpenResponses } from "./open-responses.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
@@ -96,15 +94,9 @@ const OpenAIResponsesToolChoice = Schema.Union([
Schema.Struct({ type: Schema.tag("image_generation") }),
])
const OpenAIResponsesInputItem = Schema.Union([
OpenResponses.InputItem,
OpenAIResponsesHostedToolItem,
OpenResponses.ConfigurationUpdate,
])
const OpenAIResponsesCoreFields = {
...OpenResponses.coreFields,
input: Schema.Array(OpenAIResponsesInputItem),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
tools: optionalArray(OpenAIResponsesTools),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
context_management: Schema.optional(
@@ -127,7 +119,7 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
const CheckpointBody = Schema.Struct({
...OpenAIResponsesBody.fields,
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
store: Schema.Literal(false),
prompt_cache_retention: optionalNull(Schema.String),
prompt_cache_options: optionalNull(
@@ -141,14 +133,6 @@ const adapter = {
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
} satisfies OpenResponses.ProviderAdapter
// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
const supportsEffortUpdates = (request: LLMRequest) => {
if (request.providerOptions?.contextManagement !== undefined) return false
const override = request.model.compatibility?.supportsEffortUpdates
if (override !== undefined) return override
return /(?:^|\/)gpt-6-astra$/i.test(request.model.id)
}
const nativeImageToolInput = (tool: ToolDefinition) => {
const native = tool.native?.openai
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
@@ -205,12 +189,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
const management = yield* ProviderShared.validateWith(
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
)(request.providerOptions?.contextManagement)
const options = OpenResponsesOptions.resolve(request)
const updates = resolveEffortUpdates(request, options.reasoningEffort)
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
return yield* decodeBody({
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
...(yield* OpenResponses.lowerConversation(request, adapter)),
...OpenResponses.lowerGeneration(request),
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
tools:
request.tools.length === 0
@@ -313,7 +295,6 @@ export const protocol = Protocol.make({
step,
terminal: OpenResponses.terminal,
},
supportsEffortUpdates,
})
const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BASE_URL })
@@ -1,7 +1,12 @@
import { Option, Schema } from "effect"
import { ReasoningEffort, ReasoningEfforts, type LLMRequest } from "../../schema/index.js"
import type { LLMRequest } from "../../schema/index.js"
export { ReasoningEffort, ReasoningEfforts }
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const TextVerbosities = ["low", "medium", "high"] as const
export type TextVerbosity = (typeof TextVerbosities)[number] | (string & {})
@@ -11,7 +11,6 @@ import {
mergeJsonRecords,
} from "../../schema/index.js"
import type { CompactOperation } from "../../route/client.js"
import { stripEffortUpdates } from "../../effort-updates.js"
import { Endpoint } from "../../route/endpoint.js"
import { RequestExecutor } from "../../route/executor.js"
import { HttpTransport } from "../../route/transport/index.js"
@@ -76,8 +75,7 @@ const Response = Schema.Struct({
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
const route = request.model.route
// The standalone compaction endpoint rejects histories containing configuration updates.
const native = yield* OpenResponses.lowerConversation(stripEffortUpdates(request), adapter)
const native = yield* OpenResponses.lowerConversation(request, adapter)
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
mergeJsonRecords(
{
+3 -8
View File
@@ -6,7 +6,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
export const id = ProviderID.make("alibaba")
@@ -82,13 +82,8 @@ export const configure = (input: Config) => {
? hosts.get(region)
: `${workspaceID}.${region}.maas.aliyuncs.com`
if (baseURL === undefined) {
if (region === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
if (host === undefined)
throw new ProviderConfigurationError({
provider: id,
message: `Alibaba region ${region} requires workspaceID or baseURL`,
})
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
}
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
@@ -4,7 +4,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("amazon-bedrock")
@@ -79,12 +79,9 @@ const defaults = (input: Config) => {
export const configure = (input: Config = {}) => {
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
if (input.auth === "sigv4" && input.apiKey !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
})
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
const configuredChatRoute = configuredRoute(chatRoute, input)
const modelDefaults = defaults(input)
+3 -4
View File
@@ -1,6 +1,6 @@
import type { RouteDefaultsInput } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as BedrockConverse from "../protocols/bedrock-converse.js"
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
@@ -39,9 +39,8 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
const configuredRoute = (input: Config) => {
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
if (auth === "sigv4" && apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
throw new Error("Amazon Bedrock bearer auth requires apiKey")
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
const resolvedRegion = BedrockAuth.resolveRegion(input)
return BedrockConverse.route.with({
...rest,
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { RouteDefaultsInput } from "../route/client.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
@@ -36,12 +36,8 @@ const auth = (input: ProviderAuthOption<"optional">) => {
}
export const configure = (input: Config) => {
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
const provider = input.provider ?? "anthropic-compatible"
if (!input.baseURL)
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: "Anthropic-compatible providers require a baseURL",
})
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
const route = AnthropicMessages.route.with({
...rest,
@@ -65,13 +61,8 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
modelID,
settings,
) => {
// Read before the exclusivity check narrows a conflicting settings object to `never`.
const provider = ProviderID.make(settings.provider ?? id)
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new ProviderConfigurationError({
provider,
message: "Anthropic-compatible apiKey cannot be combined with authToken",
})
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
+2 -5
View File
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
import { Auth } from "../route/auth.js"
import type { ProviderAuthOption } from "../route/auth-options.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
import { AnthropicCompatible } from "./anthropic-compatible.js"
@@ -57,10 +57,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
settings,
) => {
if (settings.apiKey !== undefined && settings.authToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Anthropic apiKey cannot be combined with authToken",
})
throw new Error("Anthropic apiKey cannot be combined with authToken")
return configure({
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
baseURL: settings.baseURL,
+2 -2
View File
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
import type { ProviderPackage } from "../provider-package.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import * as OpenAIResponses from "../protocols/openai-responses.js"
import { ProviderShared } from "../protocols/shared.js"
@@ -163,7 +163,7 @@ const config = (settings: Settings): Config => {
}
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
throw new Error("Azure requires resourceName or baseURL")
}
export const responsesModel: ProviderPackage.Definition<
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-ai-gateway")
@@ -35,11 +35,7 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: GatewayURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
})
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
}
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
export const id = ProviderID.make("cloudflare-workers-ai")
@@ -28,11 +28,7 @@ export type Settings = ProviderPackage.Settings &
export const baseURL = (input: WorkersAIURL) => {
if (input.baseURL) return input.baseURL
if (!input.accountId)
throw new ProviderConfigurationError({
provider: id,
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
})
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
}
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenAIChat } from "../protocols/openai-chat.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
@@ -37,8 +37,7 @@ const route = Route.make({
export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -75,8 +74,7 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
@@ -67,7 +67,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
throw new Error("Google Vertex Messages does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -107,8 +107,7 @@ export const model: ProviderPackage.Definition<Settings, AnthropicMessages.Provi
modelID,
settings,
) => {
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
import { OpenResponses } from "../protocols/open-responses.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
import { ProviderID, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
@@ -39,7 +39,7 @@ export const routes = [route]
const configuredRoute = (input: Config) => {
if ("apiKey" in input && input.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
throw new Error("Google Vertex Responses does not support API keys")
const {
accessToken: _accessToken,
auth: _auth,
@@ -79,8 +79,7 @@ export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOp
modelID,
settings,
) => {
if (settings.apiKey !== undefined)
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
return configure({
accessToken: settings.accessToken,
baseURL: settings.baseURL,
@@ -1,10 +1,8 @@
import type { AnyAuthClient } from "google-auth-library"
import { Effect, Redacted } from "effect"
import { Auth, MissingCredentialError } from "../route/auth.js"
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
const id = ProviderID.make("google-vertex")
export type OAuthOptions =
| { readonly accessToken?: string; readonly auth?: never }
@@ -37,18 +35,12 @@ export const host = (location: string) => {
export const requireProject = (value: string | undefined) => {
if (value) return value
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex requires a project when baseURL is not configured",
})
throw new Error("Google Vertex requires a project when baseURL is not configured")
}
export const apiKey = (input: ApiKeyOptions) => {
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
}
@@ -76,10 +68,7 @@ const adc = (project?: string) => {
export const oauth = (input: OAuthOptions, project?: string) => {
if (input.accessToken !== undefined && input.auth !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex accessToken cannot be combined with auth",
})
throw new Error("Google Vertex accessToken cannot be combined with auth")
if (input.auth) return input.auth
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
return adc(project)
+3 -9
View File
@@ -6,7 +6,7 @@ import { Auth } from "../route/auth.js"
import { Route, type RouteDefaultsInput } from "../route/client.js"
import { Endpoint } from "../route/endpoint.js"
import { Framing } from "../route/framing.js"
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
import { GoogleVertexShared } from "./google-vertex-shared.js"
export interface GeminiOptionsInput extends Gemini.OptionsInput {
@@ -93,10 +93,7 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
const apiKey = GoogleVertexShared.apiKey(input)
const endpointModel = String(modelID).startsWith("endpoints/")
if (apiKey !== undefined && endpointModel)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex tuned models do not support Express Mode API keys",
})
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
const location = GoogleVertexShared.location(inputLocation, "us-central1")
const project = GoogleVertexShared.project(inputProject)
const endpoint =
@@ -126,10 +123,7 @@ export const provider = {
}
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
throw new ProviderConfigurationError({
provider: id,
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
})
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
return configure({
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
baseURL: settings.baseURL,
+2 -11
View File
@@ -7,7 +7,6 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { applyEffortUpdates } from "../effort-updates.js"
import { normalizeToolHistory } from "../tool-history.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
@@ -24,7 +23,6 @@ import {
LanguageModel,
LLMEvent,
InvalidProviderOutputError,
ProviderConfigurationError,
ProviderID,
mergeGenerationOptions,
mergeHttpOptions,
@@ -56,7 +54,6 @@ export interface Route<
readonly transport: Transport<Body, Prepared, unknown>
readonly defaults: RouteDefaults
readonly body: RouteBody<Body>
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
readonly with: {
<Next extends CompactionOperations | undefined>(
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
@@ -131,10 +128,7 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
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))
throw new ProviderConfigurationError({
provider: ProviderID.make(provider),
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
})
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
return LanguageModel.make<Options, Compact>({
...mapped,
provider,
@@ -390,7 +384,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
transport: routeInput.transport,
defaults: routeInput.defaults ?? {},
body: protocol.body,
supportsEffortUpdates: protocol.supportsEffortUpdates,
with: (patch: RoutePatch<Body, Prepared>) => {
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
return build({
@@ -561,9 +554,7 @@ const prepareRequest = (request: LLMRequest) => {
[...new Map(tools.map((tool) => [`${tool.type}:${tool.name}`, tool])).values()].map((tool) =>
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
)
const resolved = applyCachePolicy(
applyEffortUpdates(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })),
)
const resolved = applyCachePolicy(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) }))
const headers = resolved.model.route.headers?.({ request: resolved })
return headers === undefined
? resolved
-2
View File
@@ -41,8 +41,6 @@ export interface Protocol<Body, Frame, Event, State> {
readonly body: ProtocolBody<Body>
/** Response side: streaming state machine. */
readonly stream: ProtocolStream<Frame, Event, State>
/** Whether `body.from` lowers `Message.effort(...)` markers; wrappers around another `body.from` must forward it. */
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
}
export interface ProtocolBody<Body> {
-13
View File
@@ -50,19 +50,6 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
route: Schema.optional(RouteID),
}) {}
/**
* Provider settings that are missing, conflicting, or unsupported, such as
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
* provider facade or package entrypoint configures a model, before any
* request exists, so it is not an `AIError` reason.
*/
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
"AI.Error.ProviderConfiguration",
)("ProviderConfiguration", {
provider: ProviderID,
message: Schema.String,
}) {}
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
...ReasonFields,
route: RouteID,
-13
View File
@@ -9,7 +9,6 @@ import {
LanguageModelSchema,
type LanguageModel,
ProviderOptions,
ReasoningEffort,
} from "./options.js"
import { ProviderID } from "./ids.js"
@@ -218,14 +217,6 @@ export const CompactionPart = Object.assign(compactionPartSchema, {
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
})
/** Reasoning effort changed here, from `previous` to `effort`; `undefined` is the model default. */
export const EffortPart = Schema.Struct({
type: Schema.Literal("effort"),
effort: Schema.optional(ReasoningEffort),
previous: Schema.optional(ReasoningEffort),
}).annotate({ identifier: "LLM.Content.Effort" })
export type EffortPart = Schema.Schema.Type<typeof EffortPart>
export const ContentPart = Schema.Union([
TextPart,
MediaPart,
@@ -233,7 +224,6 @@ export const ContentPart = Schema.Union([
ToolResultPart,
ReasoningPart,
CompactionPart,
EffortPart,
]).pipe(Schema.toTaggedUnion("type"))
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
@@ -275,9 +265,6 @@ export namespace Message {
*/
export const system = (content: SystemContentInput) => make({ role: "system", content })
export const effort = (input: { readonly effort?: ReasoningEffort; readonly previous?: ReasoningEffort }) =>
make({ role: "system", content: [{ type: "effort", effort: input.effort, previous: input.previous }] })
export const tool = (result: ToolResultPart | Parameters<typeof ToolResultPart.make>[0]) =>
make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
}
-10
View File
@@ -140,14 +140,6 @@ export namespace LanguageModelDefaults {
}
}
/** Ordered lowest to highest. */
export const ReasoningEfforts = ["none", "minimal", "low", "medium", "high", "xhigh", "max"] as const
export type ReasoningEffort = (typeof ReasoningEfforts)[number] | (string & {})
export const ReasoningEffort = Schema.declare<ReasoningEffort>(
(value): value is ReasoningEffort => typeof value === "string",
{ title: "ReasoningEffort" },
)
export const LanguageModelToolSchemaCompatibility = Schema.Literals(["gemini", "moonshot"])
export type LanguageModelToolSchemaCompatibility = Schema.Schema.Type<typeof LanguageModelToolSchemaCompatibility>
@@ -173,8 +165,6 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
requireSignature: Schema.optional(Schema.Boolean),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
supportsThinkingBlockBinding: Schema.optional(Schema.Boolean),
/** Supports per-message effort updates. Overrides model-ID detection. */
supportsEffortUpdates: Schema.optional(Schema.Boolean),
}) {}
export namespace LanguageModelCompatibility {
-439
View File
@@ -1,439 +0,0 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMRequest, Message, ToolCallPart } from "../src/index.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
import { AnthropicMessages } from "../src/protocols/anthropic-messages.js"
import { OpenAIResponses } from "../src/protocols/openai-responses.js"
import { Gemini } from "../src/protocols/gemini.js"
import { GoogleVertexMessages, OpenAI } from "../src/providers.js"
import { applyCachePolicy } from "../src/cache-policy.js"
import { applyEffortUpdates } from "../src/effort-updates.js"
import { it, testEffect } from "./lib/effect.js"
import { dynamicResponse } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
const anthropic = (id: string, compatibility?: { readonly supportsEffortUpdates?: boolean }) =>
AnthropicMessages.route
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
.model({ id, compatibility })
const openai = (id: string, compatibility?: { readonly supportsEffortUpdates?: boolean }) =>
OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id, compatibility })
const opus5 = anthropic("claude-opus-5")
const astra = openai("gpt-6-astra")
const lowFromHigh = Message.effort({ effort: "low", previous: "high" })
const conversation = [Message.user("Before."), lowFromHigh, Message.user("After.")]
const systemMessages = (body: AnthropicMessages.AnthropicMessagesBody) =>
body.messages.filter((message) => message.role === "system")
const updates = (body: OpenAIResponses.OpenAIResponsesBody) =>
body.input.filter((item) => "type" in item && item.type === "configuration_update")
describe("applyEffortUpdates", () => {
test("keeps the request identity without markers and for protocols that lower them", () => {
const plain = LLM.request({ model: opus5, prompt: "hi" })
expect(applyEffortUpdates(plain)).toBe(plain)
const supported = LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" } })
expect(applyEffortUpdates(supported)).toBe(supported)
})
it.effect("compiles markers away for protocols without per-message effort", () =>
Effect.gen(function* () {
const model = Gemini.route
.with({
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
auth: Auth.header("x-goog-api-key", "test"),
})
.model({ id: "gemini-3.5-flash" })
const withMarkers = yield* compileRequest(LLM.request({ model, messages: conversation }))
const withoutMarkers = yield* compileRequest(
LLM.request({ model, messages: [Message.user("Before."), Message.user("After.")] }),
)
expect(withMarkers.body).toEqual(withoutMarkers.body)
}),
)
})
describe("cache policy", () => {
it.effect("walks the tail breakpoint back past a trailing effort marker", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [Message.user("first"), Message.assistant("reply"), Message.user("latest"), lowFromHigh],
providerOptions: { effort: "low" },
cache: "auto",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "first" }] },
{ role: "assistant", content: [{ type: "text", text: "reply" }] },
{ role: "user", content: [{ type: "text", text: "latest", cache_control: { type: "ephemeral" } }] },
{ role: "system", content: [], output_config: { effort: "low" } },
])
}),
)
})
describe("Anthropic Messages effort updates", () => {
it.effect("lowers markers to per-turn system messages and freezes the top-level effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "low" }, cache: "none" }),
)
expect(prepared.body.output_config).toEqual({ effort: "high" })
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Before." }] },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: [{ type: "text", text: "After." }] },
])
}),
)
it.effect("omits the frozen effort for the model default and sends `high` for a switch back to it", () =>
Effect.gen(function* () {
const format = { type: "json_schema" as const, schema: { type: "object" } }
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [
Message.user("One."),
Message.effort({ effort: "low" }),
Message.user("Two."),
Message.effort({ previous: "low" }),
Message.user("Three."),
],
providerOptions: { output_config: { format } },
cache: "none",
}),
)
expect(prepared.body.output_config).toEqual({ format })
expect(systemMessages(prepared.body)).toEqual([
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "system", content: [], output_config: { effort: "high" } },
])
}),
)
it.effect("requests the mid-conversation output config beta only when markers are sent", () =>
Effect.gen(function* () {
for (const [model, betas] of [
[opus5, "existing-beta,mid-conversation-output-config-2026-07-01"],
[anthropic("claude-sonnet-5"), "existing-beta"],
] as const) {
const request = LLM.request({
model,
messages: conversation,
providerOptions: { effort: "low" },
http: { headers: { "anthropic-beta": "existing-beta" } },
})
const compiled = yield* compileRequest(request)
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
expect(prepared.request.headers["anthropic-beta"]).toBe(betas)
}
}),
)
it.effect("accepts a marker between a tool call and its result", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: opus5,
messages: [
Message.user("Weather?"),
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
lowFromHigh,
Message.tool({ id: "call_1", name: "lookup", result: { temp: 72 } }),
],
providerOptions: { effort: "low" },
cache: "none",
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: [{ type: "text", text: "Weather?" }] },
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: {} }] },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"temp":72}' }] },
])
}),
)
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
Effect.gen(function* () {
const drifted = yield* compileRequest(
LLM.request({ model: opus5, messages: conversation, providerOptions: { effort: "medium" }, cache: "none" }),
)
const plain = yield* compileRequest(
LLM.request({
model: opus5,
messages: [Message.user("Before."), Message.user("After.")],
providerOptions: { effort: "medium" },
cache: "none",
}),
)
expect(drifted.body).toEqual(plain.body)
expect(drifted.body.output_config).toEqual({ effort: "medium" })
}),
)
for (const [id, supported] of [
["claude-opus-5", true],
["claude-opus-5-20260901", true],
["anthropic/claude-opus-5", true],
["claude-fable-5-1", true],
["claude-mythos-5-1", true],
["claude-fable-5", false],
["claude-opus-4-8", false],
["claude-sonnet-5", false],
["kimi-k2.5", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: anthropic(id), messages: conversation, providerOptions: { effort: "low" } }),
)
expect(systemMessages(prepared.body)).toHaveLength(supported ? 1 : 0)
expect(prepared.body.output_config).toEqual({ effort: supported ? "high" : "low" })
}),
)
}
it.effect("honors the compatibility override in both directions", () =>
Effect.gen(function* () {
const enabled = yield* compileRequest(
LLM.request({
model: anthropic("claude-sonnet-5", { supportsEffortUpdates: true }),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
const disabled = yield* compileRequest(
LLM.request({
model: anthropic("claude-opus-5", { supportsEffortUpdates: false }),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(enabled.body)).toHaveLength(1)
expect(systemMessages(disabled.body)).toHaveLength(0)
}),
)
it.effect("strips markers on the Vertex Anthropic route, whose protocol wrapper does not forward support", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: GoogleVertexMessages.configure({ accessToken: "test", location: "global", project: "test" }).model(
"claude-opus-5",
),
messages: conversation,
providerOptions: { effort: "low" },
}),
)
expect(systemMessages(prepared.body)).toHaveLength(0)
expect(prepared.body.output_config).toEqual({ effort: "low" })
}),
)
})
describe("OpenAI Responses effort updates", () => {
it.effect("lowers markers to configuration_update items and freezes reasoning.effort", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "low" } }),
)
expect(prepared.body.reasoning).toEqual({ effort: "high" })
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "low" } },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
}),
)
it.effect("coalesces consecutive updates so the newest wins", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: astra,
messages: [
Message.user("Before."),
Message.effort({ effort: "low", previous: "medium" }),
Message.effort({ effort: "xhigh", previous: "low" }),
Message.user("After."),
],
providerOptions: { reasoningEffort: "xhigh" },
}),
)
expect(prepared.body.reasoning).toEqual({ effort: "medium" })
expect(prepared.body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "xhigh" } },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
}),
)
it.effect("omits reasoning.effort for the model default and sends `medium` for a switch back to it", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: astra,
messages: [
Message.user("One."),
Message.effort({ effort: "low" }),
Message.user("Two."),
Message.effort({ previous: "low" }),
Message.user("Three."),
],
}),
)
expect(prepared.body.reasoning).toBeUndefined()
expect(updates(prepared.body)).toEqual([
{ type: "configuration_update", reasoning: { effort: "low" } },
{ type: "configuration_update", reasoning: { effort: "medium" } },
])
}),
)
it.effect("falls back to a plain top-level effort when history drifted from the current effort", () =>
Effect.gen(function* () {
const drifted = yield* compileRequest(
LLM.request({ model: astra, messages: conversation, providerOptions: { reasoningEffort: "xhigh" } }),
)
const plain = yield* compileRequest(
LLM.request({
model: astra,
messages: [Message.user("Before."), Message.user("After.")],
providerOptions: { reasoningEffort: "xhigh" },
}),
)
expect(drifted.body).toEqual(plain.body)
expect(drifted.body.reasoning).toEqual({ effort: "xhigh" })
}),
)
it.effect("strips markers when automatic context management is enabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: astra,
messages: conversation,
providerOptions: { reasoningEffort: "low", contextManagement: [{ type: "compaction" }] },
}),
)
expect(updates(prepared.body)).toEqual([])
expect(prepared.body.reasoning).toEqual({ effort: "low" })
expect(prepared.body.context_management).toEqual([{ type: "compaction" }])
}),
)
for (const [id, supported] of [
["gpt-6-astra", true],
["openai/gpt-6-astra", true],
["gpt-6-astra-2026-09-01", false],
["gpt-5.6-sol", false],
] as const) {
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({ model: openai(id), messages: conversation, providerOptions: { reasoningEffort: "low" } }),
)
expect(updates(prepared.body)).toHaveLength(supported ? 1 : 0)
expect(prepared.body.reasoning).toEqual({ effort: supported ? "high" : "low" })
}),
)
}
it.effect("honors the compatibility override in both directions", () =>
Effect.gen(function* () {
const enabled = yield* compileRequest(
LLM.request({
model: openai("gpt-5.5", { supportsEffortUpdates: true }),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
}),
)
const disabled = yield* compileRequest(
LLM.request({
model: openai("gpt-6-astra", { supportsEffortUpdates: false }),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
}),
)
expect(updates(enabled.body)).toHaveLength(1)
expect(updates(disabled.body)).toHaveLength(0)
}),
)
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
const compactRequest = LLM.request({
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-6-astra"),
messages: conversation,
providerOptions: { reasoningEffort: "low" },
})
testEffect(
dynamicResponse(({ text, respond }) =>
Effect.sync(() => {
const body = JSON.parse(text)
expect(body.reasoning).toEqual({ effort: "high" })
expect(body.input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ type: "configuration_update", reasoning: { effort: "low" } },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
{ type: "compaction_trigger" },
])
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
).effect("keeps configuration updates in the checkpoint body", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(compactRequest, { mechanism: "trigger" })
expect(result.checkpoint.encrypted).toBe("opaque")
}),
)
testEffect(
dynamicResponse(({ request, text, respond }) =>
Effect.sync(() => {
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
expect(JSON.parse(text).input).toEqual([
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
{ role: "user", content: [{ type: "input_text", text: "After." }] },
])
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
}),
),
).effect("drops markers from the compaction endpoint body", () =>
Effect.gen(function* () {
const result = yield* LLMClient.compact(compactRequest, { mechanism: "endpoint" })
expect(result.replacement.map((message) => message.content[0]?.type)).toEqual(["compaction"])
}),
)
})
+11 -16
View File
@@ -3,9 +3,6 @@ import { model } from "@opencode/ai/providers/openai"
import { LLM } from "../src/index.js"
import { Endpoint } from "../src/route/endpoint.js"
const configuration = (provider: string, message: string) =>
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
describe("provider package entrypoints", () => {
test("semantic API aliases expose the same contract", async () => {
const modules = await Promise.all([
@@ -325,7 +322,7 @@ describe("provider package entrypoints", () => {
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
expect(() =>
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
).toThrow("Anthropic-compatible providers require a baseURL")
})
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
@@ -340,10 +337,10 @@ describe("provider package entrypoints", () => {
baseURL: "https://messages.example.test/v1",
},
]),
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
expect(() =>
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
).toThrow("Anthropic apiKey cannot be combined with authToken")
})
test("maps legacy OpenAI organization and project settings to headers", () => {
@@ -493,45 +490,43 @@ describe("provider package entrypoints", () => {
"gemini-3.5-flash",
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
{ accessToken: "token", auth: {}, project: "vertex-project" },
])
expect(() => configured.model("gemini-3.5-flash")).toThrow(
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
)
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
expect(() =>
Reflect.apply(GoogleVertexMessages.model, undefined, [
"claude-sonnet-4-6",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
).toThrow("Google Vertex Messages does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexChat.model, undefined, [
"deepseek-ai/deepseek-v3.2-maas",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
).toThrow("Google Vertex Chat does not support API keys")
expect(() =>
Reflect.apply(GoogleVertexResponses.model, undefined, [
"xai/grok-4.20-reasoning",
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
).toThrow("Google Vertex Responses does not support API keys")
expect(() =>
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
{ apiKey: "fixture", project: "vertex-project" },
]),
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
).toThrow("Google Vertex Responses does not support API keys")
})
})
+1 -7
View File
@@ -76,13 +76,7 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
expect(() => Alibaba.configure({ region })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "alibaba",
message: `Alibaba region ${region} requires workspaceID or baseURL`,
}),
)
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
for (const config of [
{ baseURL: "https://gateway.example/prefix" },
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
@@ -1458,13 +1458,7 @@ describe("Bedrock Converse route", () => {
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
}
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "amazon-bedrock",
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
}),
)
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
}).pipe(
withProcessEnv({
...noAmbientAWS,
@@ -378,11 +378,7 @@ describe("Google Vertex providers", () => {
test("rejects tuned Gemini models in express mode", () => {
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
expect.objectContaining({
_tag: "ProviderConfiguration",
provider: "google-vertex",
message: "Google Vertex tuned models do not support Express Mode API keys",
}),
"Google Vertex tuned models do not support Express Mode API keys",
)
})
})
@@ -19,13 +19,11 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toBeVisible()
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
await expect(dialog).toHaveCount(0)
})
@@ -45,17 +43,6 @@ story("adding a server keeps all SSH challenges in the original connection dialo
await expect(dialog).toHaveCount(0)
})
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
await mount("app-dialog-ssh--incompatible-host")
const dialog = page.getByRole("dialog")
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
await expect(dialog.getByRole("status")).toContainText("Server update required")
await expect(dialog.getByRole("textbox")).toHaveCount(0)
await expect(dialog.getByRole("alert")).toHaveCount(0)
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
})
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
const component = await mount("app-dialog-ssh--incompatible-session")
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
})
const group = page.locator('[data-component="collapsed-tool-group"]')
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
await timeline.send(
partUpdated(
toolPart(
@@ -134,6 +134,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
"true",
)
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
})
+2 -6
View File
@@ -54,12 +54,8 @@ export function createWebPlatform(version: string) {
function getCurrentServerUrl() {
if (import.meta.env.VITE_OPENCODE_SERVER_MODE === "none") return undefined
if (import.meta.env.DEV) {
const loopback =
location.hostname === "localhost" || location.hostname === "[::1]" || location.hostname.startsWith("127.")
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? (loopback ? location.hostname : "localhost")
return `http://${host}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
}
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return location.origin
}
+2 -5
View File
@@ -10,7 +10,6 @@ import { createData } from "@opencode/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
import { ModelState } from "./persistence"
@@ -34,7 +33,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
const models = createGlobalModels()
const notificationCoordinator = createNotificationCoordinator()
const settingsServer = createMemo(() => {
const list = server.list
@@ -59,7 +57,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
if (existing) return existing
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)
return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator)
return createServerController(conn, server.scope(key), server.projects.forServer(key))
}, owner)
serverCtxs.set(key, serverCtx)
return serverCtx
@@ -133,7 +131,6 @@ function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
notificationCoordinator: ReturnType<typeof createNotificationCoordinator>,
) {
const language = useLanguage()
const settings = useSettings()
@@ -162,7 +159,7 @@ function createServerController(
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
const notification = createServerNotificationState({ sdk, data, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -278,7 +278,6 @@ function Open(props: { initial?: string }) {
export default { title: "App/Dialogs/SSH", id: "app-dialog-ssh" }
export const AuthenticationRequired = { render: () => <Fixture initial="required" /> }
export const SettingsReconnect = { render: () => <Fixture initial="required" settings connectionDelay={200} /> }
export const IncompatibleHost = { render: () => <Fixture incompatible /> }
export const IncompatibleSession = { render: () => <Fixture initial="required" session incompatible /> }
export const InactiveSession = { render: () => <Fixture initial="required" session connectionDelay={3000} /> }
export const KeyReconnect = { render: () => <Fixture initial="required" session keyOnly connectionDelay={3000} /> }
+2 -14
View File
@@ -120,13 +120,7 @@ export function DialogSsh(props: {
<Divider />
<DialogBody class="flex w-full min-w-0 flex-1 flex-col px-4 pt-4 pb-2">
<div class="flex w-full min-w-0 flex-col gap-6">
<Show
when={
!props.promptOnly &&
item()?.stage !== "incompatible" &&
(!state.prompted || (!!error() && !prompt()))
}
>
<Show when={!props.promptOnly && (!state.prompted || (!!error() && !prompt()))}>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label" for="ssh-target">
{language.t("ssh.target")}
@@ -166,12 +160,6 @@ export function DialogSsh(props: {
/>
</div>
</Show>
<Show when={item()?.stage === "incompatible"}>
<div class="flex w-full min-w-0 flex-col gap-2" role="status" aria-live="polite">
<span class="text-14-medium text-v2-text-text-base">{language.t("ssh.stage.incompatible")}</span>
<span class="text-13-regular text-v2-text-text-muted">{language.t("ssh.error.version")}</span>
</div>
</Show>
<Show when={prompt()} keyed>
{(prompt) => (
<div class="flex w-full min-w-0 flex-col gap-2">
@@ -207,7 +195,7 @@ export function DialogSsh(props: {
</div>
)}
</Show>
<Show when={item()?.stage !== "incompatible" && error()}>
<Show when={error()}>
{(error) => (
<span class="settings-server-dialog-error !leading-[var(--line-height-compact)]" role="alert">
{error()}
@@ -1,6 +1,6 @@
import { createMemo, createUniqueId, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { createQuery, keepPreviousData } from "@tanstack/solid-query"
import { createQuery } from "@tanstack/solid-query"
import { Icon } from "@opencode/ui/icon"
import { SessionFilePanelV2, SessionFilePanelV2Empty } from "@opencode/session-ui/v2/session-file-panel-v2"
import { SessionReviewV2Sidebar } from "@opencode/session-ui/v2/session-review-v2"
@@ -56,7 +56,6 @@ export function SessionFileBrowserTab(props: {
queryKey: [serverSDK.scope, "session-open-file", workspaceKey(), value] as const,
enabled: serverSDK.connection.status() === "connected" && value.length > 0,
queryFn: ({ signal }) => file.searchFiles(value, { limit: 200, signal }),
placeholderData: keepPreviousData,
}
})
const files = createMemo(() => {
@@ -1,93 +0,0 @@
import { onCleanup } from "solid-js"
const FOCUS_LOCK = "opencode:notification-focus"
const MAX_CLAIMED = 500
export function createNotificationCoordinator() {
const locks = typeof navigator === "undefined" ? undefined : navigator.locks
const claimed = new Set<string>()
const focus = { pending: false, release: undefined as (() => void) | undefined }
const updateFocus = () => {
if (typeof document === "undefined" || !document.hasFocus()) {
focus.release?.()
return
}
if (!locks || focus.pending || focus.release) return
focus.pending = true
void locks
.request(FOCUS_LOCK, { mode: "shared" }, async () => {
focus.pending = false
if (!document.hasFocus()) return
await new Promise<void>((resolve) => {
focus.release = resolve
})
focus.release = undefined
})
.catch(() => {
focus.pending = false
})
}
if (typeof window !== "undefined") {
window.addEventListener("focus", updateFocus)
window.addEventListener("blur", updateFocus)
document.addEventListener("visibilitychange", updateFocus)
updateFocus()
onCleanup(() => {
window.removeEventListener("focus", updateFocus)
window.removeEventListener("blur", updateFocus)
document.removeEventListener("visibilitychange", updateFocus)
focus.release?.()
})
}
const once = async (kind: "sound" | "system", eventID: string, run: () => Promise<unknown> | void) => {
const key = `${kind}:${eventID}`
const execute = async () => {
if (!claim(kind, key, claimed)) return
await run()
}
if (!locks) return execute()
await locks.request(`opencode:notification:${key}`, execute)
}
return {
sound(eventID: string, run: () => Promise<unknown> | void) {
return once("sound", eventID, run)
},
system(eventID: string, run: () => Promise<unknown> | void) {
return once("system", eventID, async () => {
if (typeof document !== "undefined" && document.hasFocus()) return
if (!locks) return run()
await locks.request(FOCUS_LOCK, { mode: "exclusive", ifAvailable: true }, async (lock) => {
if (!lock) return
await run()
})
})
},
}
}
function claim(kind: "sound" | "system", eventID: string, claimed: Set<string>) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const storageKey = `opencode:notification-${kind}`
const value: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(storageKey, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
@@ -11,8 +11,7 @@ import { useSettings } from "@/settings/model"
import { decode64 } from "@/runtime/persistence/base64"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { playSoundByIdOnce } from "@/shell/notifications/sound"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
@@ -115,12 +114,7 @@ function buildNotificationIndex(list: Notification[]) {
return index
}
export function createServerNotificationState(input: {
sdk: ServerSDK
data: Data
key: ServerConnection.Key
coordinator: ReturnType<typeof createNotificationCoordinator>
}) {
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
@@ -229,7 +223,7 @@ export function createServerNotificationState(input: {
if (session.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.agentEnabled()) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
void playSoundByIdOnce(settings.sounds.agent(), `${input.key}\0${eventID}`)
}
append({
@@ -241,10 +235,8 @@ export function createServerNotificationState(input: {
})
if (settings.notifications.agent()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
),
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
openNotificationSession(tabs, input.key, sessionID),
)
}
})
@@ -256,7 +248,7 @@ export function createServerNotificationState(input: {
if (session?.parentID) return
if (sessionIDHasOpenTab(tabs.store, input.key, sessionID) && settings.sounds.errorsEnabled()) {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
void playSoundByIdOnce(settings.sounds.errors(), `${input.key}\0${eventID}`)
}
append({
@@ -271,10 +263,8 @@ export function createServerNotificationState(input: {
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
if (settings.notifications.errors()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
),
void platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
)
}
})
@@ -74,6 +74,9 @@ function getLoads() {
}
const cache = new Map<SoundID, Promise<string | undefined>>()
const claimed = new Set<string>()
const CLAIMED_STORAGE_KEY = "opencode:notification-sounds"
const MAX_CLAIMED = 500
export function soundSrc(id: string | undefined) {
const loads = getLoads()
@@ -100,3 +103,34 @@ export function playSound(src: string | undefined) {
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}
export async function playSoundByIdOnce(id: string | undefined, eventID: string) {
const play = async () => {
if (!claim(eventID)) return
await playSoundById(id)
}
if (typeof navigator === "undefined" || !navigator.locks) return play()
await navigator.locks.request(`${CLAIMED_STORAGE_KEY}:${eventID}`, play)
}
function claim(eventID: string) {
if (claimed.has(eventID)) return false
if (typeof localStorage !== "undefined") {
try {
const value: unknown = JSON.parse(localStorage.getItem(CLAIMED_STORAGE_KEY) ?? "[]")
const events = Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
if (events.includes(eventID)) {
claimed.add(eventID)
return false
}
localStorage.setItem(CLAIMED_STORAGE_KEY, JSON.stringify([...events, eventID].slice(-MAX_CLAIMED)))
} catch {
// The in-memory claim still prevents duplicates in this renderer when storage is unavailable.
}
}
claimed.add(eventID)
return true
}
+1 -5
View File
@@ -76,10 +76,7 @@ export function buildEffortSelectOption(input: {
category: "thought_level",
type: "select",
currentValue: selectVariant(input.currentVariant, input.variants),
options: [...new Set([...input.variants, DEFAULT_VARIANT_VALUE])].map((variant) => ({
value: variant,
name: formatVariantName(variant),
})),
options: input.variants.map((variant) => ({ value: variant, name: formatVariantName(variant) })),
}
}
@@ -128,7 +125,6 @@ export function formatVariantName(variant: string) {
}
function selectVariant(variant: string | undefined, variants: readonly string[]) {
if (!variant || variant === DEFAULT_VARIANT_VALUE) return DEFAULT_VARIANT_VALUE
if (variant && variants.includes(variant)) return variant
if (variants.includes(DEFAULT_VARIANT_VALUE)) return DEFAULT_VARIANT_VALUE
return variants[0] ?? DEFAULT_VARIANT_VALUE
+2 -4
View File
@@ -201,7 +201,7 @@ export async function streamTurn(input: {
if (!child) assistantMessageID = event.data.assistantMessageID
await send({
sessionUpdate: "agent_thought_chunk",
messageId: `${event.data.assistantMessageID}:reasoning:${event.data.ordinal}`,
messageId: event.data.assistantMessageID,
content: { type: "text", text: event.data.delta },
})
continue
@@ -455,8 +455,6 @@ async function replayMessage(
return
}
if (message.type !== "assistant") return
// Live reasoning ordinals count only reasoning parts, not the mixed content array.
let reasoningOrdinal = 0
for (const part of message.content) {
if (part.type === "text") {
await connection.sessionUpdate({
@@ -474,7 +472,7 @@ async function replayMessage(
sessionId: sessionID,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: `${message.id}:reasoning:${reasoningOrdinal++}`,
messageId: message.id,
content: { type: "text", text: part.text },
},
})
+5 -20
View File
@@ -41,12 +41,7 @@ import type {
} from "@agentclientprotocol/sdk"
import { OPENCODE_VERSION } from "../version"
import { SessionMessage } from "@opencode/schema/session-message"
import {
buildConfigOptions,
DEFAULT_VARIANT_VALUE,
parseModelSelection,
type ConfigOptionProvider,
} from "./config-option"
import { buildConfigOptions, parseModelSelection, type ConfigOptionProvider } from "./config-option"
import { promptContentToParts } from "./content"
import {
ChildSessionUpdateMethod,
@@ -280,7 +275,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
if (typeof params.value !== "string") throw new ACPError.InvalidConfigOptionError({ configId: params.configId })
switch (params.configId) {
case "model": {
const selected = requireModel(state.catalog, params.value, state.model)
const selected = requireModel(state.catalog, params.value)
state.model = selected
await input.client.session.switchModel({ sessionID: state.id, model: selected })
break
@@ -289,10 +284,7 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
const model = state.catalog.models.find(
(item) => item.providerID === state.model.providerID && item.id === state.model.id,
)
if (
!model ||
(params.value !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === params.value))
)
if (!model?.variants.some((variant) => variant.id === params.value))
throw new ACPError.InvalidEffortError({ effort: params.value })
state.model = { ...state.model, variant: params.value }
await input.client.session.switchModel({ sessionID: state.id, model: state.model })
@@ -461,7 +453,7 @@ function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
}))
}
function requireModel(catalog: Catalog, modelID: string, current: ModelRef): ModelRef {
function requireModel(catalog: Catalog, modelID: string): ModelRef {
const selected = parseModelSelection(modelID, catalog.providers)
const model = catalog.models.find(
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
@@ -469,14 +461,7 @@ function requireModel(catalog: Catalog, modelID: string, current: ModelRef): Mod
if (!model) throw new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
throw new ACPError.InvalidEffortError({ effort: selected.variant })
const variant =
selected.variant ??
(current.providerID === model.providerID &&
current.id === model.id &&
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
? current.variant
: undefined)
return { providerID: model.providerID, id: model.id, variant }
return { providerID: model.providerID, id: model.id, variant: selected.variant }
}
async function selectMode(client: OpenCodeClient, state: Attached, modeID: string) {
@@ -50,11 +50,11 @@ describe("acp config option subprocess", () => {
const effort = requireSelectOption((await newSession(acp, fixture.home)).configOptions, "effort")
expect(effort.category).toBe("thought_level")
expect(effort.currentValue).toBe("default")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high", "default"])
expect(effort.currentValue).toBe("low")
expect(flattenSelectOptions(effort).map((option) => option.value)).toEqual(["low", "high"])
}, 60_000)
test("effort survives model synchronization and can be reset to default", async () => {
test("effort switch updates currentValue", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
await initialize(acp)
@@ -70,23 +70,5 @@ describe("acp config option subprocess", () => {
)
expect(selectConfigOption(updated.configOptions, "effort")?.currentValue).toBe(nextEffort)
const synchronized = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "model",
value: requireSelectOption(session.configOptions, "model").currentValue,
}),
)
expect(selectConfigOption(synchronized.configOptions, "effort")?.currentValue).toBe(nextEffort)
const reset = expectOk(
await acp.request<SetSessionConfigOptionResponse>("session/set_config_option", {
sessionId: session.sessionId,
configId: "effort",
value: "default",
}),
)
expect(selectConfigOption(reset.configOptions, "effort")?.currentValue).toBe("default")
}, 60_000)
})
+6 -44
View File
@@ -91,7 +91,7 @@ describe("acp event behavior", () => {
}
})
test("preserves reasoning boundaries and update order during streaming and replay", async () => {
test("preserves text and reasoning order before returning the terminal response", async () => {
const firstUpdate = Promise.withResolvers<void>()
const releaseUpdate = Promise.withResolvers<void>()
const allUpdates = Promise.withResolvers<void>()
@@ -108,14 +108,6 @@ describe("acp event behavior", () => {
delta: "think-1",
}),
)
send(
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 0,
delta: " continued",
}),
)
send(
ephemeralEvent("session.text.delta", {
sessionID: "ses_order",
@@ -128,7 +120,7 @@ describe("acp event behavior", () => {
ephemeralEvent("session.reasoning.delta", {
sessionID: "ses_order",
assistantMessageID: "msg_order",
ordinal: 1,
ordinal: 2,
delta: "think-2",
}),
)
@@ -152,7 +144,7 @@ describe("acp event behavior", () => {
firstUpdate.resolve()
await releaseUpdate.promise
}
if (updates.length === 4) allUpdates.resolve()
if (updates.length === 3) allUpdates.resolve()
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
} satisfies Connection
@@ -179,48 +171,18 @@ describe("acp event behavior", () => {
) {
return [
item.update.sessionUpdate,
item.update.messageId,
item.update.content.type === "text" ? item.update.content.text : undefined,
]
}
return [item.update.sessionUpdate, undefined]
}),
).toEqual([
["agent_thought_chunk", "msg_order:reasoning:0", "think-1"],
["agent_thought_chunk", "msg_order:reasoning:0", " continued"],
["agent_message_chunk", "msg_order", "answer"],
["agent_thought_chunk", "msg_order:reasoning:1", "think-2"],
["agent_thought_chunk", "think-1"],
["agent_message_chunk", "answer"],
["agent_thought_chunk", "think-2"],
])
expect(fixture.requests.at(-1)?.path).toBe("/api/session/ses_order/message/msg_order")
expect(response).toMatchObject({ stopReason: "end_turn", usage: { totalTokens: 2 } })
const replayed: SessionUpdateParams[] = []
await replayMessages(recordingConnection(replayed), "ses_order", "/workspace", [
{
id: "msg_order",
type: "assistant",
agent: "build",
model: { providerID: "test", id: "test-model" },
time: { created: 1 },
content: [
{ type: "reasoning", text: "think-1 continued" },
{ type: "text", text: "answer" },
{ type: "reasoning", text: "think-2" },
],
},
])
expect(replayed).toEqual([
{
sessionId: "ses_order",
update: {
sessionUpdate: "agent_thought_chunk",
messageId: "msg_order:reasoning:0",
content: { type: "text", text: "think-1 continued" },
},
},
updates[2],
updates[3],
])
} finally {
releaseUpdate.resolve()
releaseSubmit.resolve()
@@ -222,7 +222,7 @@ describe("acp service directory behavior", () => {
await fixture.service.setSessionMode({ sessionId: session.sessionId, modeId: "build" })
expect(currentValue(selectedModel, "model")).toBe("test/second-model")
expect(currentValue(selectedModel, "effort")).toBe("default")
expect(currentValue(selectedModel, "effort")).toBe("low")
expect(currentValue(selectedEffort, "effort")).toBe("medium")
expect(currentValue(selectedMode, "mode")).toBe("plan")
expect(
@@ -32,7 +32,7 @@ describe("acp service lifecycle", () => {
model: { providerID: "test", id: "second-model" },
},
})
expect(currentValue(created, "effort")).toBe("default")
expect(currentValue(created, "effort")).toBe("none")
})
test("loads and forks with paginated replay while resume does not replay", async () => {
+3 -2
View File
@@ -5,8 +5,9 @@ import { coerceToString } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies.
const base64 = (name: "atob" | "btoa") =>
sync(name, (args, node) => {
if (args.length === 0)
throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node).as("TypeError")
if (args.length === 0) {
throw new InterpreterRuntimeError(`${name} requires 1 argument, but only 0 were provided.`, node).as("TypeError")
}
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
-8
View File
@@ -547,14 +547,6 @@ function assistantPart(part: ContentPart): AssistantContent {
]
case "tool-result":
return toolResultPart(part)
case "effort":
throw ProviderShared.unsupportedContent("AI SDK", "assistant", [
"text",
"media",
"reasoning",
"tool-call",
"tool-result",
])
}
}
+16 -2
View File
@@ -8,6 +8,7 @@ import { Provider } from "./provider.js"
import { Bus } from "./bus.js"
import { State } from "./state.js"
import { Integration } from "./integration.js"
import { ProviderPolicy } from "./provider-policy.js"
export type ProviderRecord = {
provider: Provider.MutableInfo
@@ -63,6 +64,7 @@ const layer = Layer.effect(
Effect.gen(function* () {
const bus = yield* Bus.Service
const integrations = yield* Integration.Service
const policies = yield* ProviderPolicy.Service
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
if (provider.activation === "disabled") return false
@@ -146,11 +148,15 @@ const layer = Layer.effect(
provider: {
get: Effect.fn("Catalog.provider.get")(function* (providerID) {
if (!ProviderPolicy.allows(yield* policies.read(), providerID)) return
return state.get().providers.get(providerID)?.provider
}),
all: Effect.fn("Catalog.provider.all")(function* () {
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
const policy = yield* policies.read()
return Array.fromIterable(state.get().providers.values())
.map((record) => record.provider)
.filter((provider) => ProviderPolicy.allows(policy, provider.id))
}),
available: Effect.fn("Catalog.provider.available")(function* () {
@@ -163,6 +169,7 @@ const layer = Layer.effect(
model: {
get: Effect.fn("Catalog.model.get")(function* (providerID, modelID) {
if (!ProviderPolicy.allows(yield* policies.read(), providerID)) return
const record = state.get().providers.get(providerID)
if (!record) return
const model = record.models.get(modelID)
@@ -170,8 +177,10 @@ const layer = Layer.effect(
}),
all: Effect.fn("Catalog.model.all")(function* () {
const policy = yield* policies.read()
return pipe(
Array.fromIterable(state.get().providers.values()),
Array.filter((record) => ProviderPolicy.allows(policy, record.provider.id)),
Array.flatMap((record) => {
return Array.fromIterable(record.models.values()).map((model) => projectModel(model, record.provider))
}),
@@ -209,6 +218,7 @@ const layer = Layer.effect(
}),
small: Effect.fn("Catalog.model.small")(function* (providerID) {
if (!ProviderPolicy.allows(yield* policies.read(), providerID)) return
const record = state.get().providers.get(providerID)
if (!record) return
const models = pipe(
@@ -237,4 +247,8 @@ const layer = Layer.effect(
const SMALL_MODEL_FAMILY_PRIORITY = ["gpt-luna", "gemini-flash-lite", "gemini-flash", "claude-haiku"]
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Integration.node] })
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, Integration.node, ProviderPolicy.node],
})
-27
View File
@@ -1,27 +0,0 @@
export * as ConfigPolicyPlugin from "./policy.js"
import { define } from "@opencode/plugin/effect/plugin"
import { Document } from "@opencode/schema/config"
import { Effect } from "effect"
import { Config } from "../../config.js"
import { Wildcard } from "../../util/wildcard.js"
import { ConfigEntryObserver } from "./entry-observer.js"
export const Plugin = define({
id: "opencode.config.policy",
effect: Effect.fn(function* (ctx) {
const config = yield* Config.Service
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.catalog.reload())
yield* ctx.catalog.transform((catalog) => {
// User-global policy takes priority over policy authored by a repository.
const policies = loaded.entries
.filter((entry): entry is Document => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
for (const record of catalog.provider.list()) {
const policy = policies.findLast((policy) => Wildcard.match(record.provider.id, policy.resource))
if (policy?.effect === "deny") catalog.provider.remove(record.provider.id)
}
})
}),
})
+4 -2
View File
@@ -41,8 +41,6 @@ export const layer = Layer.effect(
[
"SessionRunnerModel.VariantUnavailableError",
"SessionRunnerModel.UnsupportedPackageError",
"SessionRunnerModel.ModelConfigurationError",
"SessionRunnerModel.ModelInitializationError",
"SessionRunnerModel.UnresolvedProviderVariablesError",
"SessionRunnerModel.UnsupportedCompactionError",
],
@@ -74,6 +72,10 @@ export const layer = Layer.effect(
const text: Interface["text"] = (input) =>
runText(input).pipe(
Effect.catchTag(
["ProviderPolicy.Unavailable", "ProviderPolicy.Denied"],
(error) => new UnavailableError({ message: error.message }),
),
Effect.catchTag(
"Integration.Authorization",
() =>
+12 -74
View File
@@ -1,7 +1,7 @@
export * as ModelResolver from "./model-resolver.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { LanguageModel, ProviderConfigurationError } from "@opencode/ai"
import { LanguageModel } from "@opencode/ai"
import { Auth } from "@opencode/ai/route"
import { Context, Effect, Layer, Schema, Struct } from "effect"
import { AISDK } from "./aisdk.js"
@@ -12,6 +12,7 @@ import { Integration } from "./integration.js"
import { Capabilities, ID, Info, Ref, VariantID } from "./model.js"
import { Npm } from "@opencode/util/npm"
import { Provider } from "./provider.js"
import { ProviderPolicy } from "./provider-policy.js"
export class VariantUnavailableError extends Schema.TaggedError<VariantUnavailableError>()(
"SessionRunnerModel.VariantUnavailableError",
@@ -39,40 +40,6 @@ export class UnsupportedPackageError extends Schema.TaggedError<UnsupportedPacka
}
}
export const InitializationPhase = Schema.Literals(["load", "init", "construct"])
export type InitializationPhase = typeof InitializationPhase.Type
/** Provider settings are missing, conflicting, or unsupported; the provider's own message tells the user what to fix. */
export class ModelConfigurationError extends Schema.TaggedError<ModelConfigurationError>()(
"SessionRunnerModel.ModelConfigurationError",
{
providerID: Provider.ID,
modelID: ID,
package: Schema.String,
detail: Schema.String,
},
) {
override get message() {
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
}
}
/** A supported package failed unexpectedly while loading or constructing the model. */
export class ModelInitializationError extends Schema.TaggedError<ModelInitializationError>()(
"SessionRunnerModel.ModelInitializationError",
{
providerID: Provider.ID,
modelID: ID,
package: Schema.String,
phase: InitializationPhase,
detail: Schema.String,
},
) {
override get message() {
return `Cannot initialize ${this.providerID}/${this.modelID}: ${this.detail}`
}
}
export class UnresolvedProviderVariablesError extends Schema.TaggedError<UnresolvedProviderVariablesError>()(
"SessionRunnerModel.UnresolvedProviderVariablesError",
{
@@ -100,10 +67,10 @@ export class UnsupportedCompactionError extends Schema.TaggedError<UnsupportedCo
}
export type Error =
| ProviderPolicy.Unavailable
| ProviderPolicy.Denied
| VariantUnavailableError
| UnsupportedPackageError
| ModelConfigurationError
| ModelInitializationError
| UnresolvedProviderVariablesError
| UnsupportedCompactionError
| Integration.AuthorizationError
@@ -171,11 +138,7 @@ export const fromCatalogModel = (
dependencies?: Dependencies,
): Effect.Effect<
LanguageModel,
| UnsupportedPackageError
| ModelConfigurationError
| ModelInitializationError
| UnresolvedProviderVariablesError
| UnsupportedCompactionError
UnsupportedPackageError | UnresolvedProviderVariablesError | UnsupportedCompactionError
> =>
resolveCatalogModel(model, credential, dependencies).pipe(
Effect.flatMap((resolved) => validateProviderVariables(model, resolved)),
@@ -218,16 +181,14 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
...configuration,
}) ?? {},
)
return yield* loadAISDK({ ...resolved, settings }).pipe(
Effect.mapError((error) => initialization(resolved, "init", error.cause)),
)
return yield* loadAISDK({ ...resolved, settings }).pipe(Effect.mapError(() => unsupported(resolved)))
}
if (!native) return yield* unsupported(resolved)
const specifier = native
const mapped = yield* prepareProviderSettings(resolved, mapping?.settings ?? configured)
const module = yield* (dependencies?.loadPackage ?? Provider.loadPackage)(specifier).pipe(
Effect.mapError((error) => initialization(resolved, "load", error.cause)),
Effect.mapError(() => unsupported(resolved)),
)
const settings = {
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
@@ -246,15 +207,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
: runtime.compatibility,
})
},
catch: (cause) =>
cause instanceof ProviderConfigurationError
? new ModelConfigurationError({
providerID: resolved.providerID,
modelID: resolved.id,
package: resolved.package ?? "unknown",
detail: cause.message,
})
: initialization(resolved, "construct", cause),
catch: () => unsupported(resolved),
})
})
@@ -327,24 +280,6 @@ const unsupported = (model: Info) =>
package: model.package ?? "unknown",
})
const initialization = (model: Info, phase: InitializationPhase, cause: unknown) =>
new ModelInitializationError({
providerID: model.providerID,
modelID: model.id,
package: model.package ?? "unknown",
phase,
detail: causeMessage(cause) ?? `${phase} failed for ${model.package ?? "unknown"}`,
})
// Unexpected throws still carry the most useful diagnosis in their message; a stack or an unknown value does not.
const causeMessage = (cause: unknown): string | undefined => {
if (typeof cause === "string") return cause.trim() || undefined
if (!(cause instanceof globalThis.Error)) return undefined
const message = cause.message.trim()
if (message) return message
return causeMessage(cause.cause)
}
export const resolveModel = (
model: Info,
variant: VariantID | undefined,
@@ -359,10 +294,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const policies = yield* ProviderPolicy.Service
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) {
yield* policies.assert(selected.providerID)
const provider = yield* catalog.provider.get(selected.providerID)
const connection = yield* integrations.connection.active(
provider?.integrationID ?? Integration.ID.make(selected.providerID),
@@ -396,6 +333,7 @@ export const layer = Layer.effect(
})
return Service.of({
resolve: Effect.fn("ModelResolver.resolve")(function* (requested) {
yield* policies.assert(requested?.providerID)
const selected = requested
? yield* catalog.model.get(requested.providerID, requested.id)
: yield* catalog.model
@@ -462,5 +400,5 @@ function usesAPIKeyAuth(packageName: string | undefined) {
export const node = makeLocationNode({
service: Service,
layer,
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node],
deps: [Catalog.node, Integration.node, Npm.node, AISDK.node, ProviderPolicy.node],
})
+3 -2
View File
@@ -20,7 +20,7 @@ import { ConfigInstructionPlugin } from "../config/plugin/instruction.js"
import { ConfigLocationWatcherPlugin } from "../config/plugin/location-watcher.js"
import { ConfigMcpPlugin } from "../config/plugin/mcp.js"
import { ConfigProviderPlugin } from "../config/plugin/provider.js"
import { ConfigPolicyPlugin } from "../config/plugin/policy.js"
import { ProviderPolicy } from "../provider-policy.js"
import { ConfigReferencePlugin } from "../config/plugin/reference.js"
import { ConfigShellPlugin } from "../config/plugin/shell.js"
import { ConfigSnapshotPlugin } from "../config/plugin/snapshot.js"
@@ -98,6 +98,7 @@ const services = [
Agent.Service,
AppProcess.Service,
Catalog.Service,
ProviderPolicy.Service,
Command.Service,
Config.Service,
Credential.Service,
@@ -147,6 +148,7 @@ export const requirements = LayerNode.group([
Agent.node,
AppProcess.node,
Catalog.node,
ProviderPolicy.node,
Command.node,
Config.node,
Credential.node,
@@ -241,7 +243,6 @@ const post = [
ConfigWebSearchPlugin.Plugin,
ConfigWorktreePlugin.Plugin,
VariantPlugin.Plugin,
ConfigPolicyPlugin.Plugin,
] as const satisfies readonly InternalPlugin[]
export const list = Effect.fn("PluginInternal.list")(function* () {
+2 -19
View File
@@ -11,7 +11,6 @@ import PROMPT_ASTRA from "./system-prompt/gpt-astra.txt"
import PROMPT_KIMI from "./system-prompt/kimi.txt"
import PROMPT_META from "./system-prompt/meta.txt"
import PROMPT_TRINITY from "./system-prompt/trinity.txt"
import PROMPT_ANTHROPIC from "./system-prompt/anthropic.txt"
export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
const id = model.id.toLowerCase()
@@ -19,19 +18,6 @@ export const OpenAIPlugin = make("opencode.prompt.openai", (model) => {
return id.includes("gpt-6") ? PROMPT_ASTRA : PROMPT_GPT
})
export const AnthropicPlugin = make(
"opencode.prompt.anthropic",
(model) => {
const id = model.id.toLowerCase()
if (!id.includes("claude")) return undefined
return PROMPT_ANTHROPIC
},
"append",
)
// Both OpenAIToolsPlugin and AnthropicToolsPlugin are disabled intentionally until we can figure out a good ux for displaying
// heavy grep/glob usage done via shell or other mechanisms
export const OpenAIToolsPlugin = make("opencode.optimize.openai.tools", (model, tools) => {
const ids = [model.id, model.modelID, model.family].join(" ").toLowerCase()
if (!ids.includes("gpt")) return undefined
@@ -59,12 +45,11 @@ export const MetaPlugin = make("opencode.prompt.meta", (model) => {
return PROMPT_META.replaceAll("{{MODEL_NAME}}", model.name)
})
export const Plugins = [OpenAIPlugin, AnthropicPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
export const Plugins = [OpenAIPlugin, KimiPlugin, ArceePlugin, MetaPlugin] as const
function make(
id: string,
optimize: (model: Model.Info, tools: SessionHooks["context"]["tools"]) => string | undefined,
mode: "override" | "append" = "override",
) {
return define({
id,
@@ -81,9 +66,7 @@ function make(
if ((yield* ctx.agent.get({ agentID: event.agent })).data.system) return
const system = event.system[0]
if (!system) return
const rendered = SessionSystemPrompt.render(template, Object.keys(event.tools))
const text = mode === "append" ? `${system.text}\n\n${rendered}` : rendered
event.system[0] = { ...system, text }
event.system[0] = { ...system, text: SessionSystemPrompt.render(template, Object.keys(event.tools)) }
}).pipe(Effect.catch(() => Effect.void))
yield* ctx.session.hook("context", hook)
yield* ctx.session.hook("compaction", hook)
+85 -11
View File
@@ -10,6 +10,8 @@ import { Provider } from "../../provider.js"
import { WebSearch } from "../../websearch.js"
import { ConfigProvider } from "@opencode/schema/config/provider"
import { Money } from "@opencode/schema/money"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { ProviderPolicy } from "../../provider-policy.js"
const defaultServer = "https://opencode.ai/console"
const clientID = "opencode-cli"
@@ -19,7 +21,27 @@ const RemoteResponse = Schema.Struct({
websearch: Schema.Struct({
providerID: WebSearch.ID,
}).pipe(Schema.optional),
experimental: Schema.Unknown,
managedPolicy: Schema.Unknown,
})
const RemotePolicy = Schema.Struct({
experimental: Schema.Struct({
policies: Schema.Array(ConfigPolicy.Info).check(
Schema.makeFilter((statements) =>
statements.every(
(statement) =>
statement.resource.length > 0 &&
statement.resource.length <= 256 &&
statement.resource.trim() === statement.resource,
),
),
),
}),
managedPolicy: ProviderPolicy.Descriptor,
})
class RemoteFailure extends Schema.TaggedError<RemoteFailure>()("OpenCode.RemoteConfigFailure", {
transient: Schema.Boolean,
}) {}
const Device = Schema.Struct({
device_code: Schema.String,
user_code: Schema.String,
@@ -102,31 +124,41 @@ function oauth(http: HttpClient.HttpClient) {
} satisfies IntegrationOAuthMethodRegistration
}
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope.Scope>({
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | ProviderPolicy.Service | Scope.Scope>({
id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) {
const bus = yield* Bus.Service
const http = yield* HttpClient.HttpClient
const policies = yield* ProviderPolicy.Service
const loading = Semaphore.makeUnsafe(1)
type ActiveConnection = Effect.Success<ReturnType<typeof ctx.integration.connection.active>>
let snapshot: {
config: typeof RemoteResponse.Type | undefined
config: Effect.Success<ReturnType<typeof fetchConfig>> | undefined
connection: ActiveConnection
} = { config: undefined, connection: undefined }
identity?: string
stale: boolean
} = { config: undefined, connection: undefined, stale: false }
const load = Effect.fn("OpencodePlugin.load")(function* () {
const connection = yield* ctx.integration.connection.active("opencode")
const credential = connection
? yield* ctx.integration.connection.resolve(connection).pipe(Effect.orElseSucceed(() => undefined))
: undefined
const identity = connection && credential ? ProviderPolicy.identity(connection, credential) : undefined
const config = credential
? yield* fetchConfig(http, credential).pipe(
Effect.catch((cause) =>
Effect.logWarning("failed to load OpenCode provider config", { cause }).pipe(Effect.as(undefined)),
Effect.logWarning("Failed to load workspace provider policy", { transient: cause.transient }).pipe(
Effect.as(
cause.transient && identity !== undefined && snapshot.identity === identity && snapshot.config
? { ...snapshot.config, stale: true }
: undefined,
),
),
),
)
: undefined
return { config, connection }
return { config, connection, identity, stale: config?.stale ?? false }
})
yield* ctx.integration.transform((editor) => {
@@ -138,6 +170,16 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
})
snapshot = yield* load()
yield* policies.set(
snapshot.config && snapshot.identity
? {
identity: snapshot.identity,
descriptor: snapshot.config.managedPolicy,
statements: snapshot.config.experimental.policies,
stale: snapshot.stale,
}
: undefined,
)
yield* ctx.catalog.transform((catalog) => {
for (const [providerID, item] of Object.entries(snapshot.config?.providers ?? {})) {
const source = catalog.provider.get(item.canonical ?? providerID)
@@ -284,6 +326,16 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
snapshot = next
yield* policies.set(
next.config && next.identity
? {
identity: next.identity,
descriptor: next.config.managedPolicy,
statements: next.config.experimental.policies,
stale: next.stale,
}
: undefined,
)
yield* Effect.all([ctx.catalog.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
})
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
@@ -307,27 +359,49 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
}),
})
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
const fetchConfig = Effect.fn("OpenCode.fetchConfig")(function* (http: HttpClient.HttpClient, value: Credential.Value) {
const metadata = value.metadata
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
const token = value.type === "oauth" ? value.access : value.key
return http
const server = yield* normalizeServer(serverUrl(value)).pipe(
Effect.mapError(() => new RemoteFailure({ transient: false })),
)
return yield* http
.execute(
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
HttpClientRequest.get(`${server}/api/v2/config`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.bearerToken(token),
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
),
)
.pipe(
Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
Effect.mapError(() => new RemoteFailure({ transient: true })),
Effect.flatMap((response) => {
if (response.status === 404) return Effect.undefined
return HttpClientResponse.filterStatusOk(response).pipe(
if (response.status < 200 || response.status >= 300)
return Effect.fail(new RemoteFailure({ transient: response.status >= 500 || response.status === 429 }))
return Effect.succeed(response).pipe(
Effect.flatMap(HttpClientResponse.schemaBodyJson(RemoteResponse)),
Effect.flatMap((config) =>
Schema.decodeUnknownEffect(RemotePolicy, { onExcessProperty: "error" })({
experimental: config.experimental,
managedPolicy: config.managedPolicy,
}).pipe(Effect.map((policy) => ({ ...config, ...policy, stale: false }))),
),
Effect.flatMap((config) =>
orgID !== undefined && config.managedPolicy.workspaceID !== orgID
? Effect.fail(new RemoteFailure({ transient: false }))
: Effect.succeed(config),
),
Effect.mapError(() => new RemoteFailure({ transient: false })),
)
}),
Effect.timeoutOrElse({
duration: Duration.seconds(20),
orElse: () => Effect.fail(new RemoteFailure({ transient: true })),
}),
)
}
})
function serverUrl(value: Credential.Value) {
return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer
+3
View File
@@ -88,6 +88,9 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* (
enabled.add(plugin.id)
}
// Console's connected-workspace policy source cannot be removed by authored
// plugin directives. Its final enforcement lives outside the plugin registry.
if (pre.some((plugin) => plugin.id === "opencode.provider.opencode")) enabled.add("opencode.provider.opencode")
const ordered = [
...pre.filter((plugin) => enabled.has(plugin.id)),
...[...packages.values()].filter((plugin) => enabled.has(plugin.id)),
@@ -1,2 +0,0 @@
# Code comments
By default, match the surrounding comment density: where the code has none, add none. Use comments sparingly, only where they are appropriate, such as for behavior that is not obvious from the code itself. Instructions from the user or the project take precedence over this guidance.
+125
View File
@@ -0,0 +1,125 @@
export * as ProviderPolicy from "./provider-policy.js"
import { ConfigPolicy } from "@opencode/schema/config/policy"
import { Event } from "@opencode/schema/config"
import { Catalog } from "@opencode/schema/catalog"
import { ProviderPolicyMatcher } from "@opencode/util/opencode-policy-matcher"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { Context, Effect, Layer, Schema, Stream } from "effect"
import { Bus } from "./bus.js"
import { Config } from "./config.js"
import { Credential } from "./credential.js"
import { Integration } from "./integration.js"
import { IntegrationConnection } from "./integration/connection.js"
export class Unavailable extends Schema.TaggedError<Unavailable>()("ProviderPolicy.Unavailable", {}) {
override get message() {
return "Workspace policy unavailable. Reconnect to OpenCode Console and try again."
}
}
export class Denied extends Schema.TaggedError<Denied>()("ProviderPolicy.Denied", { providerID: Schema.String }) {
override get message() {
return `Provider ${this.providerID} is denied by your OpenCode provider policy.`
}
}
export const Descriptor = Schema.Struct({
schemaVersion: Schema.Literal(1),
workspaceID: Schema.String.check(Schema.isNonEmpty()),
revision: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
})
export type Managed = {
readonly identity: string
readonly descriptor: typeof Descriptor.Type
readonly statements: readonly ConfigPolicy.Info[]
readonly stale: boolean
}
export type Snapshot = {
readonly status: "disconnected" | "ready" | "stale" | "unavailable"
readonly statements: readonly ConfigPolicy.Info[]
readonly workspaceID?: string
readonly revision?: number
}
export interface Interface {
readonly read: () => Effect.Effect<Snapshot>
readonly set: (value: Managed | undefined) => Effect.Effect<void>
readonly assert: (providerID?: string) => Effect.Effect<void, Unavailable | Denied>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ProviderPolicy") {}
// Internal connection binding; this value can contain a service key and must never be logged or exposed.
export function identity(
connection: Pick<IntegrationConnection.Info, "type"> & { readonly id?: string; readonly name?: string },
credential: Credential.Value,
) {
return JSON.stringify([
connection.type,
connection.id ?? connection.name,
credential.metadata?.server ?? "https://opencode.ai/console",
credential.metadata?.orgID,
credential.type === "key" ? credential.key : undefined,
])
}
export function allows(snapshot: Snapshot, providerID: string) {
return (
snapshot.status !== "unavailable" &&
ProviderPolicyMatcher.evaluate(snapshot.statements, providerID, process.platform === "win32").effect !== "deny"
)
}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const config = yield* Config.Service
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
let managed: Managed | undefined
const read = Effect.fn("ProviderPolicy.read")(function* () {
const statements = (yield* config.entries())
.filter((entry) => entry.type === "document")
.toReversed()
.flatMap((entry) => entry.info.experimental?.policies ?? [])
const connection = yield* integrations.connection.active(Integration.ID.make("opencode"))
if (!connection) return { status: "disconnected", statements } as const
const credential =
connection.type === "credential"
? (yield* credentials.get(connection.id))?.value
: process.env[connection.name]
? Credential.Key.make({ type: "key", key: process.env[connection.name]! })
: undefined
if (!credential || !managed || managed.identity !== identity(connection, credential))
return { status: "unavailable", statements } as const
return {
status: managed.stale ? "stale" : "ready",
statements: [...statements, ...managed.statements],
workspaceID: managed.descriptor.workspaceID,
revision: managed.descriptor.revision,
} as const
})
yield* bus.subscribe(Event.Updated).pipe(
Stream.runForEach(() => bus.publish(Catalog.Event.Updated, {})),
Effect.forkScoped({ startImmediately: true }),
)
return Service.of({
read,
set: (value) =>
Effect.sync(() => {
managed = value
}),
assert: Effect.fn("ProviderPolicy.assert")(function* (providerID) {
const snapshot = yield* read()
if (snapshot.status === "unavailable") return yield* new Unavailable()
if (providerID !== undefined && !allows(snapshot, providerID)) return yield* new Denied({ providerID })
}),
})
}),
)
export const node = makeLocationNode({
service: Service,
layer,
deps: [Config.node, Integration.node, Credential.node, Bus.node],
})
-1
View File
@@ -196,7 +196,6 @@ const estimateMedia = (mime: string) => {
const estimatePart = (part: ContentPart): number => {
// Encrypted checkpoints have no locally measurable token size.
if (part.type === "compaction") return Token.estimate(part.text ?? "")
if (part.type === "effort") return 0
if (part.type === "text" || part.type === "reasoning") return Token.estimate(part.text)
if (part.type === "media") return estimateMedia(part.mediaType)
if (part.type === "tool-call") return Token.estimate(part.name + (JSON.stringify(part.input) ?? ""))
+4 -5
View File
@@ -6,6 +6,7 @@ import { Model } from "@opencode/schema/model"
import { Provider } from "@opencode/schema/provider"
import { Context, Effect, Layer, Schema } from "effect"
import { ModelResolver } from "../../model-resolver.js"
import { ProviderPolicy } from "../../provider-policy.js"
import { SessionSchema } from "../schema.js"
export class ModelNotSelectedError extends Schema.TaggedError<ModelNotSelectedError>()(
@@ -33,10 +34,6 @@ export const VariantUnavailableError = ModelResolver.VariantUnavailableError
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export const ModelConfigurationError = ModelResolver.ModelConfigurationError
export type ModelConfigurationError = ModelResolver.ModelConfigurationError
export const ModelInitializationError = ModelResolver.ModelInitializationError
export type ModelInitializationError = ModelResolver.ModelInitializationError
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
@@ -84,8 +81,10 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
const policies = yield* ProviderPolicy.Service
return Service.of({
resolve: Effect.fn("SessionRunnerModel.resolve")(function* (session, available) {
yield* policies.assert(session.model?.providerID)
// Location plugins populate and filter the catalog asynchronously during layer startup.
if (!session.model) {
const resolved = yield* resolver.resolve()
@@ -106,4 +105,4 @@ const layer = Layer.effect(
}),
)
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node] })
export const node = makeLocationNode({ service: Service, layer, deps: [ModelResolver.node, ProviderPolicy.node] })
@@ -1,11 +1,4 @@
import {
Message,
ReasoningEfforts,
ToolCallPart,
ToolResultPart,
type ContentPart,
type ProviderMetadata,
} from "@opencode/ai"
import { Message, ToolCallPart, ToolResultPart, type ContentPart, type ProviderMetadata } from "@opencode/ai"
import type { Model } from "@opencode/schema/model"
import { Option, Schema } from "effect"
import { fileURLToPath } from "url"
@@ -229,31 +222,11 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
]
}
const EFFORT_VARIANTS = new Set<string>(ReasoningEfforts)
// Budget and toggle variants reuse these names; the protocol's drift check catches ones that are not effort options.
const variantEffort = (variant: Model.VariantID | undefined) => {
if (variant === undefined || variant === "default") return { effort: undefined }
return EFFORT_VARIANTS.has(variant) ? { effort: variant } : undefined
}
const modelSwitched = (message: SessionMessage.ModelSelected, model: Model.Ref): Message[] => {
const previous = message.previous
if (previous === undefined) return []
const same = (ref: Model.Ref) => ref.providerID === model.providerID && ref.id === model.id
if (!same(message.model) || !same(previous)) return []
const to = variantEffort(message.model.variant)
const from = variantEffort(previous.variant)
if (to === undefined || from === undefined) return []
return [Message.effort({ effort: to.effort, previous: from.effort })]
}
function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMetadataKey: string): Message[] {
switch (message.type) {
case "agent-switched":
return []
case "model-switched":
return modelSwitched(message, model)
return []
case "location-switched":
return [
Message.make({
@@ -3,6 +3,7 @@ import { Tool } from "@opencode/schema/tool"
import { SessionError } from "@opencode/schema/session-error"
import { Permission } from "../permission.js"
import { Integration } from "../integration.js"
import { ProviderPolicy } from "../provider-policy.js"
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
import { SessionRunnerModel } from "./runner/model.js"
@@ -51,12 +52,12 @@ export function toSessionError(cause: unknown): SessionError.Error {
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
if (
cause instanceof ProviderPolicy.Unavailable ||
cause instanceof ProviderPolicy.Denied ||
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
cause instanceof SessionRunnerModel.VariantUnavailableError ||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
cause instanceof SessionRunnerModel.ModelConfigurationError ||
cause instanceof SessionRunnerModel.ModelInitializationError ||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
)
return { type: "provider.no-route", message: cause.message }
+10 -16
View File
@@ -2,10 +2,7 @@ import { describe, expect } from "bun:test"
import { Document, Event, Info, type Entry } from "@opencode/schema/config"
import { Catalog } from "@opencode/core/catalog"
import { Config } from "@opencode/core/config"
import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
import { Bus } from "@opencode/core/bus"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { Provider } from "@opencode/core/provider"
import { Effect, Schema } from "effect"
import { testEffect } from "../lib/effect"
@@ -24,13 +21,13 @@ const policies = (...items: { effect: "allow" | "deny"; resource: string }[]) =>
}),
})
const addPlugin = Effect.fn(function* (entries: Entry[]) {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer(entries)))
const setEntries = Effect.fn(function* (entries: Entry[]) {
const config = yield* Config.Service
const current = yield* config.entries()
current.splice(0, current.length, ...entries)
})
describe("ConfigPolicyPlugin.Plugin", () => {
describe("Provider policy catalog boundary", () => {
it.effect("filters plugin-provided providers with ordered wildcard policies", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
@@ -39,7 +36,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
catalog.provider.update(Provider.ID.anthropic, () => {})
catalog.provider.update(Provider.ID.make("company-internal"), () => {})
})
yield* addPlugin([
yield* setEntries([
policies(
{ effect: "deny", resource: "*" },
{ effect: "allow", resource: "anthropic" },
@@ -57,7 +54,7 @@ describe("ConfigPolicyPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
yield* addPlugin([
yield* setEntries([
policies({ effect: "deny", resource: "openai" }),
policies({ effect: "allow", resource: "openai" }),
])
@@ -70,17 +67,14 @@ describe("ConfigPolicyPlugin.Plugin", () => {
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const bus = yield* Bus.Service
const test = yield* Config.Test
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* catalog.transform((catalog) => catalog.provider.update(Provider.ID.openai, () => {}))
yield* ConfigPolicyPlugin.Plugin.effect(host)
yield* setEntries([policies({ effect: "deny", resource: "openai" })])
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
yield* test.setEntries([policies({ effect: "allow", resource: "openai" })])
yield* setEntries([policies({ effect: "allow", resource: "openai" })])
yield* bus.publish(Event.Updated, {})
yield* waitUntil(catalog.provider.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined)))
}).pipe(Effect.provide(Config.testLayer([policies({ effect: "deny", resource: "openai" })]))),
}),
)
})
+5 -1
View File
@@ -1,3 +1,4 @@
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { expect } from "bun:test"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
@@ -67,7 +68,10 @@ const aisdk = Layer.mock(AISDK.Service, {
})
const client = TestLLM.testLayer({ fallback: TestLLM.text("OK", "generate") })
const resolver = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const resolver = ModelResolver.layer.pipe(
Layer.provide(Layer.mock(ProviderPolicy.Service, { assert: () => Effect.void })),
Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)),
)
const it = testEffect(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, client))))
const resolverIt = testEffect(resolver)
+5 -76
View File
@@ -1,3 +1,4 @@
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { describe, expect } from "bun:test"
import { LLM, LanguageModel, Message } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
@@ -384,7 +385,10 @@ describe("ModelResolver", () => {
},
model: () => Effect.die("unused"),
})
const layer = ModelResolver.layer.pipe(Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)))
const layer = ModelResolver.layer.pipe(
Layer.provide(Layer.mock(ProviderPolicy.Service, { assert: () => Effect.void })),
Layer.provide(Layer.mergeAll(catalog, integrations, npm, aisdk)),
)
return withConfigEnv({}, () =>
Effect.gen(function* () {
@@ -1395,81 +1399,6 @@ describe("ModelResolver", () => {
}),
)
it.effect("reports provider configuration errors from supported packages", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(
model(Provider.aisdk("@ai-sdk/azure"), {
providerID: Provider.ID.azure,
modelID: "gpt-5.4-nano",
}),
Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("oauth"),
access: "oauth-token",
refresh: "refresh",
expires: Date.now() + 60_000,
}),
).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelConfigurationError",
providerID: "azure",
modelID: "test-model",
package: "aisdk:@ai-sdk/azure",
detail: "Azure requires resourceName or baseURL",
})
expect(failure.message).toBe("Cannot initialize azure/test-model: Azure requires resourceName or baseURL")
}),
)
it.effect("distinguishes unexpected constructor failures from configuration errors", () =>
Effect.gen(function* () {
const failure = yield* ModelResolver.fromCatalogModel(model("@opencode/ai/providers/custom"), undefined, {
loadPackage: () =>
Effect.succeed({
model: () => {
throw new Error("custom provider crashed")
},
}),
}).pipe(Effect.flip)
expect(failure).toMatchObject({
_tag: "SessionRunnerModel.ModelInitializationError",
phase: "construct",
detail: "custom provider crashed",
})
}),
)
it.effect("reports package load and AISDK initialization failures with their causes", () =>
Effect.gen(function* () {
const load = yield* ModelResolver.fromCatalogModel(model("@opencode/ai/providers/custom"), undefined, {
loadPackage: (specifier) =>
Effect.fail(
new Provider.LoadError({ package: specifier, cause: new Error(`Provider package ${specifier} is broken`) }),
),
}).pipe(Effect.flip)
expect(load).toMatchObject({
_tag: "SessionRunnerModel.ModelInitializationError",
phase: "load",
detail: "Provider package @opencode/ai/providers/custom is broken",
})
const init = yield* ModelResolver.fromCatalogModel(model(Provider.aisdk("@ai-sdk/cohere")), undefined, {
loadAISDK: (runtime) =>
Effect.fail(
new AISDK.InitError({ providerID: runtime.providerID, cause: new Error("Cohere plugin failed") }),
),
}).pipe(Effect.flip)
expect(init).toMatchObject({
_tag: "SessionRunnerModel.ModelInitializationError",
phase: "init",
detail: "Cohere plugin failed",
})
expect(init.message).toBe("Cannot initialize test-provider/test-model: Cohere plugin failed")
}),
)
it.effect("drops an empty API key before loading an AISDK package", () =>
Effect.gen(function* () {
const native = yield* ModelResolver.fromCatalogModel(
+3
View File
@@ -1,6 +1,7 @@
import { Agent } from "@opencode/core/agent"
import { AISDK } from "@opencode/core/aisdk"
import { Catalog } from "@opencode/core/catalog"
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { Command } from "@opencode/core/command"
import { Config } from "@opencode/core/config"
import { Credential } from "@opencode/core/credential"
@@ -78,6 +79,8 @@ export const PluginTestLayer = AppNodeBuilder.build(
Agent.node,
AISDK.node,
Catalog.node,
ProviderPolicy.node,
Config.node,
Command.node,
Integration.node,
KV.node,
+2 -29
View File
@@ -19,11 +19,9 @@ import PROMPT_GPT from "../../src/plugin/system-prompt/gpt.txt"
import PROMPT_ASTRA from "../../src/plugin/system-prompt/gpt-astra.txt"
import PROMPT_KIMI from "../../src/plugin/system-prompt/kimi.txt"
import PROMPT_TRINITY from "../../src/plugin/system-prompt/trinity.txt"
import PROMPT_ANTHROPIC from "../../src/plugin/system-prompt/anthropic.txt"
const it = testEffect(PluginTestLayer)
const fallback = SessionSystemPrompt.make([])
const appended = `${fallback}\n\n${SessionSystemPrompt.render(PROMPT_ANTHROPIC, [])}`
const makeHost = Effect.gen(function* () {
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
@@ -50,7 +48,6 @@ describe("OptimizePlugin", () => {
test("enables prompt plugins without model-specific tool optimization", () => {
expect(OptimizePlugin.Plugins.map((plugin) => plugin.id)).toEqual([
"opencode.prompt.openai",
"opencode.prompt.anthropic",
"opencode.prompt.kimi",
"opencode.prompt.arcee",
"opencode.prompt.meta",
@@ -79,7 +76,7 @@ describe("OptimizePlugin", () => {
["gpt-5-codex", PROMPT_GPT],
["gpt-6-astra", PROMPT_ASTRA],
["gemini-2.5-pro", fallback],
["claude-sonnet-4", appended],
["claude-sonnet-4", fallback],
["kimi-k2", PROMPT_KIMI],
["trinity", PROMPT_TRINITY],
["meta/muse-spark-1.1", PROMPT_META.replaceAll("{{MODEL_NAME}}", "Muse Spark")],
@@ -131,30 +128,6 @@ describe("OptimizePlugin", () => {
}),
)
it.effect("appends the Anthropic prompt to the baseline without changing tools or project instructions", () =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const hooks = yield* PluginHooks.Service
const pluginHost = yield* makeHost
yield* catalog.transform((editor) =>
editor.model.update(Provider.ID.make("test"), Model.ID.make("claude-sonnet-4"), () => {}),
)
yield* OptimizePlugin.AnthropicPlugin.effect(pluginHost)
const event = context("claude-sonnet-4")
event.system.push(SystemPart.make("Project instructions"))
yield* hooks.trigger("session", "context", event)
const baseline = SessionSystemPrompt.render(fallback, Object.keys(event.tools))
expect(event.system.map((part) => part.text)).toEqual([
`${baseline}\n\n${SessionSystemPrompt.render(PROMPT_ANTHROPIC, Object.keys(event.tools))}`,
"Project instructions",
])
expect(event.system[0]?.text.startsWith(baseline)).toBe(true)
expect(Object.keys(event.tools).sort()).toEqual(["edit", "glob", "grep", "patch", "read", "shell", "write"])
}),
)
it.effect("curates search tools across providers without changing editing tools", () =>
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
@@ -325,7 +298,7 @@ describe("OptimizePlugin", () => {
["codex-family-alias", "custom-deployment", "GPT-CODEX", fallback],
["astra-api-alias", "gpt-6-astra", undefined, fallback],
["astra-family-alias", "custom-deployment", "gpt-6", fallback],
["claude-catalog-alias", "custom-model", undefined, appended],
["claude-catalog-alias", "custom-model", undefined, fallback],
["anthropic-api-alias", "Claude-Opus-4-8", undefined, fallback],
["anthropic-family-alias", "custom-deployment", "CLAUDE-SONNET", fallback],
] as const
@@ -89,6 +89,10 @@ function eventually<A>(
})
}
function managedPolicy(workspaceID = "org_test") {
return { experimental: { policies: [] }, managedPolicy: { schemaVersion: 1, workspaceID, revision: 0 } }
}
const cost = (input: number, output = 0) => [
{
input: Money.USDPerMillionTokens.make(input),
@@ -366,6 +370,7 @@ describe("OpencodePlugin", () => {
requests.push(`${request.method} ${new URL(request.url).pathname}`)
const origin = new URL(request.url).origin
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {
remote: {
canonical: "openai",
@@ -544,9 +549,10 @@ describe("OpencodePlugin", () => {
const state = { advertised: false, requests: 0 }
const server = Bun.serve({
port: 0,
fetch: () => {
fetch: (request) => {
state.requests++
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
...(state.advertised ? { websearch: { providerID: "opencode" } } : {}),
})
@@ -634,6 +640,7 @@ describe("OpencodePlugin", () => {
if (path === "/api/v2/config") {
if (state.waitForConfig) await gate.promise
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
...(state.advertised
? {
@@ -781,6 +788,7 @@ describe("OpencodePlugin", () => {
fetch: (request) => {
if (new URL(request.url).pathname === "/console/api/v2/config") {
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
websearch: {
providerID: "managed-search",
@@ -843,6 +851,7 @@ describe("OpencodePlugin", () => {
requests.push(url.pathname)
if (url.pathname === "/console/api/v2/config") {
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
websearch: {
providerID: "opencode",
@@ -893,6 +902,7 @@ describe("OpencodePlugin", () => {
const url = new URL(request.url)
if (url.pathname === "/api/v2/config") {
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {},
websearch: {
providerID: "opencode",
@@ -987,6 +997,7 @@ describe("OpencodePlugin", () => {
})
}
return Response.json({
...managedPolicy(request.headers.get("x-org-id") ?? "org_test"),
providers: {
remote: {
canonical: "openai",
@@ -1152,13 +1163,12 @@ describe("OpencodePlugin", () => {
draft.cost = cost(1)
})
})
// An env credential has no server metadata, so the plugin would ask the
// default Console for remote config; answer 404 (no remote config) locally.
// Environment credentials also require a valid managed-policy response.
yield* addPlugin().pipe(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))),
Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ providers: {}, ...managedPolicy() }))),
),
),
)
@@ -0,0 +1,51 @@
import { describe, expect } from "bun:test"
import { Catalog } from "@opencode/core/catalog"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { Provider } from "@opencode/core/provider"
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
// Run from Console's policy E2E with a freshly issued local service-account key.
describe.skipIf(!process.env.OPENCODE_CONSOLE_POLICY_URL)("Published Console policy", () => {
it.live("loads the real managed config and enforces the policy published in the browser", () =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const policies = yield* ProviderPolicy.Service
yield* catalog.transform((editor) => {
editor.provider.update(Provider.ID.openai, () => {})
editor.provider.update(Provider.ID.make("company-prod"), () => {})
})
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: process.env.OPENCODE_CONSOLE_POLICY_KEY!,
metadata: {
server: process.env.OPENCODE_CONSOLE_POLICY_URL!,
orgID: process.env.OPENCODE_CONSOLE_POLICY_ORG!,
},
}),
})
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* OpencodePlugin.effect(host)
expect(yield* policies.read()).toMatchObject({
status: "ready",
workspaceID: process.env.OPENCODE_CONSOLE_POLICY_ORG,
revision: 1,
})
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
expect(yield* catalog.provider.get(Provider.ID.make("company-prod"))).toBeDefined()
expect(yield* policies.assert("openai").pipe(Effect.flip)).toBeInstanceOf(ProviderPolicy.Denied)
}),
)
})
@@ -0,0 +1,207 @@
import { describe, expect } from "bun:test"
import { Catalog } from "@opencode/core/catalog"
import { Config } from "@opencode/core/config"
import { Credential } from "@opencode/core/credential"
import { Integration } from "@opencode/core/integration"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Model } from "@opencode/core/model"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
import { Provider } from "@opencode/core/provider"
import { ProviderPolicy } from "@opencode/core/provider-policy"
import { Document, Info } from "@opencode/schema/config"
import { Effect, Schema } from "effect"
import { TestClock } from "effect/testing"
import { drain } from "../lib/clock"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
const rule = (effect: "allow" | "deny", resource: string) => ({ action: "provider.use" as const, effect, resource })
const document = (policies: ReturnType<typeof rule>[], workspaceID = "org_policy", revision = 1) => ({
providers: {},
experimental: { policies },
managedPolicy: { schemaVersion: 1, workspaceID, revision },
})
const activate = Effect.gen(function* () {
const plugin = yield* Plugin.Service
const host = yield* PluginHost.make(plugin)
yield* OpencodePlugin.effect(host)
})
const refresh = TestClock.adjust("10 minutes").pipe(Effect.andThen(drain))
describe("Managed provider policy", () => {
it.effect("accepts revision-zero defaults without requiring policy publication", () =>
Effect.acquireUseRelease(
Effect.sync(() => Bun.serve({ port: 0, fetch: () => Response.json(document([], "org_policy", 0)) })),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const policies = yield* ProviderPolicy.Service
yield* catalog.transform((editor) => editor.provider.update(Provider.ID.openai, () => {}))
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "test-key",
metadata: { server: server.url.origin, orgID: "org_policy" },
}),
})
yield* activate
expect(yield* policies.read()).toMatchObject({ status: "ready", revision: 0, workspaceID: "org_policy" })
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeDefined()
yield* policies.assert("openai")
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
it.effect(
"composes organization rules last, filters every catalog view, retains only valid same-identity snapshots, and clears explicitly",
() =>
Effect.acquireUseRelease(
Effect.sync(() => {
const state = { status: 200, body: document([rule("deny", "*"), rule("allow", "remote")]) as unknown }
const server = Bun.serve({ port: 0, fetch: () => Response.json(state.body, { status: state.status }) })
return { state, server }
}),
({ state, server }) =>
Effect.gen(function* () {
const catalog = yield* Catalog.Service
const credentials = yield* Credential.Service
const policies = yield* ProviderPolicy.Service
const config = yield* Config.Service
const entries = yield* config.entries()
entries.push(
new Document({
type: "document",
info: Schema.decodeUnknownSync(Info)({
experimental: { policies: [rule("deny", "remote"), rule("allow", "openai")] },
}),
}),
)
yield* catalog.transform((editor) => {
for (const id of ["openai", "remote"]) {
editor.provider.update(Provider.ID.make(id), (provider) => {
provider.activation = "enabled"
})
editor.model.update(Provider.ID.make(id), Model.ID.make("model"), () => {})
}
})
const previouslySelected = yield* catalog.model.get(Provider.ID.openai, Model.ID.make("model"))
if (!previouslySelected) return yield* Effect.die("Expected local model")
const credential = yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "test-key",
metadata: { server: server.url.origin, orgID: "org_policy" },
}),
})
expect((yield* policies.read()).status).toBe("unavailable")
expect(yield* catalog.provider.all()).toEqual([])
yield* activate
expect((yield* policies.read()).status).toBe("ready")
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeDefined()
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
expect((yield* catalog.model.all()).map((model) => model.providerID)).toEqual([Provider.ID.make("remote")])
expect(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("model"))).toBeUndefined()
expect(yield* catalog.model.small(Provider.ID.openai)).toBeUndefined()
yield* catalog.transform((editor) =>
editor.provider.update(Provider.ID.openai, (provider) => {
provider.activation = "enabled"
}),
)
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
expect(yield* policies.assert("openai").pipe(Effect.flip)).toBeInstanceOf(ProviderPolicy.Denied)
const loadError = yield* Effect.gen(function* () {
const resolver = yield* ModelResolver.Service
return yield* resolver.resolveModel(previouslySelected).pipe(Effect.flip)
}).pipe(Effect.provide(ModelResolver.layer))
expect(loadError).toBeInstanceOf(ProviderPolicy.Denied)
state.status = 503
yield* refresh
expect((yield* policies.read()).status).toBe("stale")
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeDefined()
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeUndefined()
state.status = 200
state.body = { providers: {} }
yield* refresh
expect((yield* policies.read()).status).toBe("unavailable")
expect(yield* catalog.model.available()).toEqual([])
expect(yield* policies.assert("remote").pipe(Effect.flip)).toBeInstanceOf(ProviderPolicy.Unavailable)
state.body = document([], "org_policy", 2)
yield* refresh
expect((yield* policies.read()).revision).toBe(2)
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeDefined()
expect(yield* catalog.provider.get(Provider.ID.make("remote"))).toBeUndefined()
state.status = 503
yield* credentials.update(credential.id, {
value: Credential.Key.make({
type: "key",
key: "other-key",
metadata: { server: server.url.origin, orgID: "org_other" },
}),
})
yield* drain
expect((yield* policies.read()).status).toBe("unavailable")
state.status = 200
yield* refresh
expect((yield* policies.read()).status).toBe("unavailable")
state.body = document([rule("deny", "*")], "org_other", 0)
yield* refresh
expect((yield* policies.read()).workspaceID).toBe("org_other")
expect(yield* catalog.provider.all()).toEqual([])
yield* credentials.remove(credential.id)
yield* drain
expect((yield* policies.read()).status).toBe("disconnected")
expect(yield* catalog.provider.get(Provider.ID.openai)).toBeDefined()
}),
({ server }) => Effect.promise(() => server.stop(true)),
),
)
for (const scenario of [
{ name: "missing descriptor", body: { providers: {}, experimental: { policies: [] } }, status: 200 },
{
name: "unsupported version",
body: { ...document([]), managedPolicy: { schemaVersion: 2, workspaceID: "org_policy", revision: 1 } },
status: 200,
},
{ name: "unknown conditions", body: document([{ ...rule("allow", "*"), ...{ condition: {} } }]), status: 200 },
{ name: "empty resource", body: document([rule("allow", "")]), status: 200 },
{ name: "absent policies", body: { ...document([]), experimental: {} }, status: 200 },
{ name: "revoked authorization", body: document([]), status: 403 },
{ name: "old Console", body: {}, status: 404 },
{ name: "cold outage", body: {}, status: 503 },
]) {
it.effect(`fails closed on ${scenario.name}`, () =>
Effect.acquireUseRelease(
Effect.sync(() =>
Bun.serve({ port: 0, fetch: () => Response.json(scenario.body, { status: scenario.status }) }),
),
(server) =>
Effect.gen(function* () {
const credentials = yield* Credential.Service
const catalog = yield* Catalog.Service
const policies = yield* ProviderPolicy.Service
yield* catalog.transform((editor) => editor.provider.update(Provider.ID.openai, () => {}))
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.Key.make({
type: "key",
key: "test-key",
metadata: { server: server.url.origin, orgID: "org_policy" },
}),
})
yield* activate
expect((yield* policies.read()).status).toBe("unavailable")
expect(yield* catalog.provider.all()).toEqual([])
}),
(server) => Effect.promise(() => server.stop(true)),
),
)
}
})
@@ -10,6 +10,7 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { Global } from "@opencode/util/global"
import { Npm } from "@opencode/util/npm"
import { Bus } from "@opencode/core/bus"
import { Config } from "@opencode/core/config"
import { Command } from "@opencode/core/command"
import { Database } from "@opencode/core/database/database"
import { Watcher } from "@opencode/core/filesystem/watcher"
@@ -111,6 +112,36 @@ const failed = (plugins: Plugin.Interface) =>
)
describe("PluginSupervisor reload", () => {
for (const selector of ["-*", "-opencode.*", "-opencode.provider.opencode"]) {
it.live(`keeps the managed policy source active despite ${selector}`, () =>
Effect.gen(function* () {
const directory = yield* tmpdirScoped()
yield* Effect.promise(() =>
Bun.write(path.join(directory.path, ".opencode/opencode.json"), JSON.stringify({ plugins: [selector] })),
)
const locations = yield* LocationServiceMap.Service
yield* Effect.gen(function* () {
const plugins = yield* Plugin.Service
yield* plugins.awaitActivation
const config = yield* Config.Service
expect(
(yield* config.entries()).some(
(entry) => entry.type === "document" && entry.info.plugins?.includes(selector),
),
).toBe(true)
const inventory = yield* plugins.list()
expect(inventory.find((plugin) => plugin.id === "opencode.provider.opencode")?.state.status).toBe("active")
if (selector !== "-opencode.provider.opencode")
expect(
inventory.some((plugin) => plugin.id === "opencode.provider.openai" && plugin.state.status === "active"),
).toBe(false)
}).pipe(
Effect.scoped,
Effect.provide(locations.get(Location.Ref.make({ directory: AbsolutePath.make(directory.path) }))),
)
}),
)
}
;(
[
{ name: "on a helper-only save", helper: "nested/helper.ts", touchEntry: false },
-24
View File
@@ -140,30 +140,6 @@ describe("toSessionError", () => {
})
})
test("preserves provider configuration and initialization errors", () => {
const configuration = new ModelResolver.ModelConfigurationError({
providerID: Provider.ID.make("azure"),
modelID: ID.make("gpt-5.4-nano"),
package: "aisdk:@ai-sdk/azure",
detail: "Azure requires resourceName or baseURL",
})
expect(toSessionError(configuration)).toEqual({
type: "provider.no-route",
message: "Cannot initialize azure/gpt-5.4-nano: Azure requires resourceName or baseURL",
})
const initialization = new ModelResolver.ModelInitializationError({
providerID: Provider.ID.make("custom"),
modelID: ID.make("model"),
package: "@opencode/ai/providers/custom",
phase: "load",
detail: "Provider package @opencode/ai/providers/custom is broken",
})
expect(toSessionError(initialization)).toEqual({
type: "provider.no-route",
message: "Cannot initialize custom/model: Provider package @opencode/ai/providers/custom is broken",
})
})
test("retries rate limits, provider-internal, transport, and unrecognized failures", () => {
const eligible = [
llm(new RateLimitError({ message: "rate" })),
@@ -202,44 +202,6 @@ Recent work
])
})
describe("model-switched", () => {
const ref = (variant?: string) =>
Model.Ref.make({
id: Model.ID.make("model"),
providerID: Provider.ID.make("provider"),
...(variant === undefined ? {} : { variant: Model.VariantID.make(variant) }),
})
const switched = (to: Model.Ref, previous?: Model.Ref) =>
SessionMessage.ModelSelected.make({
id: id("model"),
type: "model-switched",
model: to,
previous,
time: { created },
})
test("records a same-model effort switch as an effort update", () => {
expect(toLLMMessages([switched(ref("low"), ref("high"))], ref("low"))).toEqual([
Message.effort({ effort: "low", previous: "high" }),
])
})
test("maps the default variant and no variant to the model default effort", () => {
expect(toLLMMessages([switched(ref("low"), ref())], ref("low"))).toEqual([Message.effort({ effort: "low" })])
expect(toLLMMessages([switched(ref("default"), ref("max"))], ref())).toEqual([
Message.effort({ previous: "max" }),
])
})
test("ignores switches that are not effort changes on the requested model", () => {
const other = Model.Ref.make({ id: Model.ID.make("other"), providerID: Provider.ID.make("provider") })
expect(toLLMMessages([switched(ref("low"))], ref("low"))).toEqual([])
expect(toLLMMessages([switched(ref("low"), other)], ref("low"))).toEqual([])
expect(toLLMMessages([switched(ref("thinking"), ref("high"))], ref("thinking"))).toEqual([])
expect(toLLMMessages([switched(ref("low"), ref("high"))], other)).toEqual([])
})
})
test("lowers text attachments after the prompt in one user message", () => {
const file = FileAttachment.make({
data: Base64.make(Buffer.from("export const value = 1").toString("base64")),
-33
View File
@@ -1996,39 +1996,6 @@ describe("SessionRunnerLLM", () => {
yield* s.runPrompt("Fourth")
})
scenario("records a same-model effort switch as a cache-preserving effort update", function* (s) {
s.currentModel = LanguageModel.make({ id: "claude-opus-5", provider: "anthropic", route: AnthropicMessages.route })
const model = { id: ID.make("claude-opus-5"), providerID: Provider.ID.make("anthropic") }
yield* s.bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { ...model, variant: Model.VariantID.make("high") },
})
yield* s.llm.push(TestLLM.text("Earlier answer", "text-effort-high"))
yield* s.runPrompt("First")
yield* s.bus.publish(SessionEvent.ModelSelected, {
sessionID,
model: { ...model, variant: Model.VariantID.make("low") },
})
// The selected variant reaches the request as the provider effort option.
s.currentModel = LanguageModel.update(s.currentModel, { defaults: { providerOptions: { effort: "low" } } })
yield* s.llm.push(TestLLM.text("Later answer", "text-effort-low"))
yield* s.runPrompt("Second")
expect(messageRoles(s.requests[1])).toEqual(["user", "assistant", "system", "user"])
expect(s.requests[1]?.messages[2]).toEqual(Message.effort({ effort: "low", previous: "high" }))
const compiled = yield* compileRequest(s.requests[1]!)
expect(compiled.body).toMatchObject({
output_config: { effort: "high" },
messages: [
{ role: "user" },
{ role: "assistant" },
{ role: "system", content: [], output_config: { effort: "low" } },
{ role: "user" },
],
})
})
scenario("preserves instruction values while a source is temporarily unavailable", function* (s) {
yield* s.runPrompt("First")
yield* s.bus.publish(SessionEvent.ModelSelected, {
+2 -2
View File
@@ -22,7 +22,7 @@ it.live(
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) => {
expect(request.url).toBe("https://registry.npmjs.org/@opencode%2fcli/beta")
expect(request.url).toBe("https://registry.npmjs.org/@opencode-ai%2fcli/beta")
return Effect.succeed(HttpClientResponse.fromWeb(request, response))
}),
),
@@ -65,7 +65,7 @@ posix(
test("pins platform-specific artifacts and rejects unsafe inputs", () => {
expect(RemoteCli.archiveUrl("linux-x64-baseline-musl", "2.0.0-beta.1")).toBe(
"https://registry.npmjs.org/@opencode/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
"https://registry.npmjs.org/@opencode-ai/cli-linux-x64-baseline-musl/-/cli-linux-x64-baseline-musl-2.0.0-beta.1.tgz",
)
expect(() => RemoteCli.installScript({ version: '2.0.0"; whoami', source: { type: "installer" } })).toThrow()
expect(() => RemoteCli.archiveUrl("linux-x64;whoami", "2.0.0")).toThrow()
+3 -3
View File
@@ -68,7 +68,7 @@ printf 'OPENCODE_REMOTE_TARGET=%s\\n' "$target"
export function archiveUrl(target: string, version: string) {
if (!/^(linux|darwin)-(x64-baseline|arm64)(-musl)?$/.test(target))
throw new Failure({ code: "platform", detail: target })
return `https://registry.npmjs.org/@opencode/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz`
return `https://registry.npmjs.org/@opencode-ai/cli-${target}/-/cli-${target}-${requireVersion(version)}.tgz`
}
type Source = { type: "download"; url: string } | { type: "archive" } | { type: "installer"; binary?: string }
@@ -114,12 +114,12 @@ const Beta = Schema.Struct({ version: Schema.String.check(Schema.isPattern(/^0\.
export const latestBeta = Effect.fn("RemoteCli.latestBeta")(function* () {
const http = yield* HttpClient.HttpClient
const metadata = yield* http.get("https://registry.npmjs.org/@opencode%2fcli/beta").pipe(
const metadata = yield* http.get("https://registry.npmjs.org/@opencode-ai%2fcli/beta").pipe(
Effect.flatMap(HttpClientResponse.filterStatusOk),
Effect.flatMap(HttpClientResponse.schemaBodyJson(Beta)),
Effect.timeout("30 seconds"),
Effect.mapError(
() => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode%2fcli/beta" }),
() => new Failure({ code: "install", detail: "https://registry.npmjs.org/@opencode-ai%2fcli/beta" }),
),
)
return metadata.version
@@ -9,7 +9,6 @@ export function createDesktopNotify(api: ElectronAPI): Platform["notify"] {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
silent: true,
})
notification.onclick = () => {
void api.showWindow()
@@ -1280,100 +1280,6 @@ flowchart TD
])
})
test("expands & node groups into fan-in and fan-out edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
N[Native] & M[Mapped] & O --> LM["LanguageModel"]
LM -->|prepare| REQ & LOG`)
expect(diagram.nodes).toEqual([
{ id: "N", label: "Native", shape: "box" },
{ id: "M", label: "Mapped", shape: "box" },
{ id: "O", label: "O", shape: "box" },
{ id: "LM", label: "LanguageModel", shape: "box" },
{ id: "REQ", label: "REQ", shape: "box" },
{ id: "LOG", label: "LOG", shape: "box" },
])
expect(diagram.edges).toEqual([
{ from: "N", to: "LM", label: "" },
{ from: "M", to: "LM", label: "" },
{ from: "O", to: "LM", label: "" },
{ from: "LM", to: "REQ", label: "prepare" },
{ from: "LM", to: "LOG", label: "prepare" },
])
})
test("expands & groups on both sides of an edge and through a chain", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A & B --> C & D --> E`)
expect(diagram.edges).toEqual([
{ from: "A", to: "C", label: "" },
{ from: "A", to: "D", label: "" },
{ from: "B", to: "C", label: "" },
{ from: "B", to: "D", label: "" },
{ from: "C", to: "E", label: "" },
{ from: "D", to: "E", label: "" },
])
})
test("declares every node of a bare & group inside the current subgraph", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart TD
subgraph Runtime
A[Alpha] & B[Beta]:::focus
end
A --> B`)
expect(diagram.nodes).toEqual([
{ id: "A", label: "Alpha", shape: "box" },
{ id: "B", label: "Beta", shape: "box" },
])
expect(diagram.subgraphs?.[0]?.nodeIds).toEqual(["A", "B"])
})
test("keeps & inside quoted or bracketed labels as label text", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A["Fetch & parse"] & B[R&D] --> C[Done &amp; dusted]`)
expect(diagram.nodes).toEqual([
{ id: "A", label: "Fetch & parse", shape: "box" },
{ id: "B", label: "R&D", shape: "box" },
{ id: "C", label: "Done & dusted", shape: "box" },
])
expect(diagram.edges).toEqual([
{ from: "A", to: "C", label: "" },
{ from: "B", to: "C", label: "" },
])
})
test("keeps & inside edge labels as label text", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
X[a & b] -->|x & y| Y`)
expect(diagram.nodes.find((node) => node.id === "X")?.label).toBe("a & b")
expect(diagram.edges).toEqual([{ from: "X", to: "Y", label: "x & y" }])
})
test("rejects empty & group members", () => {
for (const statement of ["A & --> B", "& A --> B", "A --> B &", "A &"]) {
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(
`Unsupported syntax in flowchart diagram at line 2: "${statement}"`,
)
}
})
test("renders a fan-in expressed with & the same as separate edge statements", () => {
const grouped = renderFlowchartDiagram(`flowchart LR
N & M & O --> LM[LanguageModel] --> REQ[LLMRequest]`)
const separate = renderFlowchartDiagram(`flowchart LR
N --> LM[LanguageModel]
M --> LM
O --> LM
LM --> REQ[LLMRequest]`)
expect(grouped).toBe(separate)
expect(grouped).toContain("LanguageModel")
})
test("parses chained undirected solid edges", () => {
const diagram = parseMermaidFlowchartDiagram(`flowchart LR
A --- B --- C`)
+25 -66
View File
@@ -127,41 +127,6 @@ function stripNodeToken(token: string): string {
.trim()
}
/** Split an `&`-joined node group, leaving `&` inside labels (brackets or quotes) untouched. */
function splitNodeGroup(token: string): string[] {
const groups: string[] = []
const stack: string[] = []
let quote: '"' | "'" | undefined
let start = 0
const closes: Record<string, string> = { "[": "]", "(": ")", "{": "}" }
for (let index = 0; index < token.length; index++) {
const character = token[index]!
if (quote) {
if (character === quote && token[index - 1] !== "\\") quote = undefined
continue
}
if (character === '"' || character === "'") {
quote = character
continue
}
if (character in closes) {
stack.push(character)
continue
}
if (stack.length > 0 && character === closes[stack.at(-1)!]) {
stack.pop()
continue
}
if (stack.length === 0 && character === "&") {
groups.push(token.slice(start, index))
start = index + 1
}
}
groups.push(token.slice(start))
return groups
}
function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined {
if (arrows.some((arrow) => arrow.includes("=="))) return "thick"
if (arrows.some((arrow) => arrow.includes("."))) return "dashed"
@@ -340,57 +305,51 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram
const edgeOperators = parseEdgeOperators(line)
if (edgeOperators.length > 0) {
// Each chain position may be an `&` group (`A & B --> C`), so endpoints are lists of node tokens.
const nodeGroups = [
const nodeTokens = [
line.slice(0, edgeOperators[0]!.index),
...edgeOperators.map((operator, index) =>
line.slice(operator.end, edgeOperators[index + 1]?.index ?? line.length),
),
].map((group) => splitNodeGroup(group).map(stripNodeToken))
]
if (nodeGroups.every((group) => group.every((token) => token.length > 0))) {
const unsupportedEndpoint = nodeGroups.find((group, index) => {
if (nodeTokens.every((token) => stripNodeToken(token).length > 0)) {
const unsupportedEndpoint = nodeTokens.find((token, index) => {
const stripped = stripNodeToken(token)
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
return group.some(
(stripped) =>
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
!isSupportedNodeToken(stripped),
return (
!(orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) &&
!isSupportedNodeToken(stripped)
)
})
if (unsupportedEndpoint) throw new MermaidSyntaxError("flowchart", source.lineNumber, line)
const chainNodeIds = nodeGroups.map((group, index) => {
const chainNodeIds = nodeTokens.map((token, index) => {
const stripped = stripNodeToken(token)
const orderOnlyEndpoint = edgeOperators[index - 1]?.orderOnly || edgeOperators[index]?.orderOnly
return group.map((stripped) => {
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
return ensureNode(nodes, stripped).id
})
if (orderOnlyEndpoint && subgraphs.some((subgraph) => subgraph.id === stripped)) return stripped
return ensureNode(nodes, stripped).id
})
for (const nodeId of chainNodeIds.flat()) {
for (const nodeId of chainNodeIds) {
if (nodes.has(nodeId)) addNodeToSubgraph(currentSubgraph, nodeId)
}
for (let index = 0; index < edgeOperators.length; index++) {
const operator = edgeOperators[index]!
for (const from of chainNodeIds[index]!) {
for (const to of chainNodeIds[index + 1]!) {
const edge = createEdge(
from,
to,
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
}
const edge = createEdge(
chainNodeIds[index]!,
chainNodeIds[index + 1]!,
operator.label,
operator.style,
operator.arrowhead,
operator.sourceArrowhead,
)
edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge)
}
continue
}
}
const nodeGroup = splitNodeGroup(line)
if (nodeGroup.every(isSupportedNodeToken)) {
for (const token of nodeGroup) addNodeToSubgraph(currentSubgraph, ensureNode(nodes, stripNodeToken(token)).id)
if (isSupportedNodeToken(line)) {
const node = ensureNode(nodes, line)
addNodeToSubgraph(currentSubgraph, node.id)
continue
}
+1 -1
View File
@@ -29,7 +29,7 @@ describe("parser diagnostics", () => {
})
test("does not partially parse unsupported flowchart syntax", () => {
for (const statement of ["A & --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
for (const statement of ["A & B --> C", "A((Start)) --> B", "A-->B; B-->C"]) {
expect(() => parseMermaidFlowchartDiagram(`flowchart LR\n ${statement}`)).toThrow(MermaidSyntaxError)
}
})
@@ -597,7 +597,7 @@ function groupContent(
detail?: TimelineDetail,
): PartGroup[] {
const groups: PartGroup[] = []
let adjacent: { type: "context" | "file"; refs: PartRef[]; tools: boolean } | undefined
let adjacent: { type: "context" | "patch" | "edit"; refs: PartRef[]; tools: boolean } | undefined
const flush = () => {
const current = adjacent
const first = current?.refs[0]
@@ -665,7 +665,8 @@ function toolGroupType(
const category = timelineCategory(content)!
if (detail[category].placement === "grouped") return "context"
if (currentToolFailed(content)) return undefined
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
if (content.name === "patch") return "patch"
if (content.name === "edit") return "edit"
return undefined
}
if (content.name === "question" || currentToolHasLoadedFiles(content)) return undefined
@@ -683,7 +684,8 @@ function toolGroupType(
)
return undefined
if (currentContentDefaultOpen(content, shellExpanded, editExpanded) !== true) return "context"
if (content.name === "patch" || content.name === "edit" || content.name === "write") return "file"
if (content.name === "patch") return "patch"
if (content.name === "edit") return "edit"
return undefined
}
@@ -691,13 +691,6 @@ describe("current session timeline rows", () => {
state: { status: "running", input: {}, metadata: { files: [] } },
time: { created: 10 },
},
{
type: "tool",
id: "tool_write_1",
name: "write",
state: { status: "running", input: {}, metadata: { files: [] } },
time: { created: 11 },
},
],
time: { created: 2, completed: 8 },
},
@@ -723,11 +716,14 @@ describe("current session timeline rows", () => {
{
type: "file",
key: "part:msg_assistant:tool_patch_3",
refs: [{ messageID: "msg_assistant", partID: "tool_patch_3" }],
},
{
type: "file",
key: "part:msg_assistant:tool_edit_1",
refs: [
{ messageID: "msg_assistant", partID: "tool_patch_3" },
{ messageID: "msg_assistant", partID: "tool_edit_1" },
{ messageID: "msg_assistant", partID: "tool_edit_2" },
{ messageID: "msg_assistant", partID: "tool_write_1" },
],
},
])
@@ -794,8 +790,8 @@ describe("current session timeline rows", () => {
test.each([
{ shell: false, edit: false, types: ["context"] },
{ shell: true, edit: false, types: ["part", "context"] },
{ shell: false, edit: true, types: ["context", "file", "context"] },
{ shell: true, edit: true, types: ["part", "file", "context"] },
{ shell: false, edit: true, types: ["context", "file", "part", "file", "context"] },
{ shell: true, edit: true, types: ["part", "file", "part", "file", "context"] },
])("keeps tools expanded by settings outside collapsed groups ($shell, $edit)", ({ shell, edit, types }) => {
const source = [
{ id: "msg_user", type: "user", text: "work", time: { created: 1 } },
+113 -131
View File
@@ -16,12 +16,12 @@ import {
type JSX,
} from "solid-js"
import stripAnsi from "strip-ansi"
import { createTwoFilesPatch } from "diff"
import { Dynamic } from "solid-js/web"
import { type SessionSummary, useData } from "../context"
import { useFileComponent } from "@opencode/ui/context/file"
import { type UiI18n, useI18n } from "@opencode/ui/context/i18n"
import { BasicTool, GenericTool } from "../components/basic-tool"
import { Collapsible } from "@opencode/ui/collapsible"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon, type IconProps } from "@opencode/ui/icon"
import { ToolErrorCard } from "../components/tool-error-card"
@@ -837,24 +837,8 @@ export function CurrentFileToolGroup(props: {
const files = createMemo((previous: { key: string; toolID: string; value: unknown }[]) => {
const next = props.tools.flatMap((tool) => {
const files = currentToolMetadata(tool).files
if (Array.isArray(files) && files.length > 0)
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
if (tool.name !== "write") return []
const input = currentToolInput(tool)
if (typeof input.path !== "string" || typeof input.content !== "string" || !input.content) return []
return [
{
key: `${tool.id}:0`,
toolID: tool.id,
value: {
file: input.path,
patch: createTwoFilesPatch(input.path, input.path, "", input.content),
additions: input.content.split("\n").length - Number(input.content.endsWith("\n")),
deletions: 0,
status: "modified",
},
},
]
if (!Array.isArray(files)) return []
return files.map((value, index) => ({ key: `${tool.id}:${index}`, toolID: tool.id, value }))
})
const updates = new Map(next.map((entry) => [entry.key, entry.value]))
const existing = new Set(previous.map((entry) => entry.key))
@@ -880,10 +864,7 @@ export function CurrentFileToolGroup(props: {
props.tools.some((tool) => tool.state.status === "streaming" || tool.state.status === "running"),
)
const render = ToolRegistry.render("patch") ?? GenericTool
const tool = createMemo(() => {
const name = props.tools[0]?.name
return name === "edit" || name === "write" ? name : "patch"
})
const tool = createMemo(() => (props.tools[0]?.name === "edit" ? "edit" : "patch"))
return (
<div
@@ -998,7 +979,7 @@ export const ToolRegistry = {
render: getTool,
}
function FileTool(props: ToolProps & { title: string; count: number; children?: JSX.Element }) {
function FileTool(props: ToolProps & { title: string; count: number; children: JSX.Element }) {
const i18n = useI18n()
return (
<BasicTool
@@ -1939,37 +1920,40 @@ ToolRegistry.register({
ToolRegistry.register({
name: "write",
render(props) {
const i18n = useI18n()
const fileComponent = useFileComponent()
const path = createMemo(() => (typeof props.input.path === "string" ? props.input.path : ""))
const content = createMemo(() => (typeof props.input.content === "string" ? props.input.content : ""))
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, path()))
return (
<div data-component="write-tool">
<Show when={content() && path()}>
<ToolFileAccordion
path={path()}
defaultOpen={props.defaultOpen}
open={props.open}
onOpenChange={props.onOpenChange}
forceOpen={props.forceOpen}
defer={props.deferContent !== false}
>
<div data-component="write-content">
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path(),
contents: content(),
cacheKey: checksum(content()),
}}
overflow="scroll"
onRendered={props.onContentRendered}
/>
</div>
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
<FileTool {...props} title={i18n.t("ui.messagePart.title.write")} count={path() ? 1 : 0}>
<Show when={path()}>
<ToolFileAccordion
path={path()}
defaultOpen={props.defaultOpen}
open={props.open}
onOpenChange={props.onOpenChange}
forceOpen={props.forceOpen}
defer={props.deferContent !== false}
>
<div data-component="write-content">
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path(),
contents: content(),
cacheKey: checksum(content()),
}}
overflow="scroll"
onRendered={props.onContentRendered}
/>
</div>
</ToolFileAccordion>
</Show>
<DiagnosticsDisplay diagnostics={diagnostics()} />
</FileTool>
</div>
)
},
@@ -1983,11 +1967,7 @@ ToolRegistry.register({
const files = createMemo(() => patchFileGroups(props.metadata.files))
const [expanded, setExpanded] = createSignal<string[]>([])
const title = createMemo(() =>
props.tool === "edit"
? i18n.t("ui.messagePart.title.edit")
: props.tool === "write"
? i18n.t("ui.messagePart.title.write")
: i18n.t("ui.tool.patch"),
props.tool === "edit" ? i18n.t("ui.messagePart.title.edit") : i18n.t("ui.tool.patch"),
)
const open = createMemo(() => {
if (!props.fileOpen) return expanded()
@@ -2004,90 +1984,92 @@ ToolRegistry.register({
return (
<div data-component="apply-patch-tool">
<Show when={files().length > 0} fallback={<FileTool {...props} title={title()} count={0} />}>
<FileAccordionGroup>
<Index each={files()}>
{(file) => {
const value = () => file().path
const active = createMemo(() => open().includes(value()))
const [visible, setVisible] = createSignal(false)
<FileTool {...props} title={title()} count={files().length}>
<Show when={files().length > 0}>
<FileAccordionGroup>
<Index each={files()}>
{(file) => {
const value = () => file().path
const active = createMemo(() => open().includes(value()))
const [visible, setVisible] = createSignal(false)
createEffect(() => {
if (!active()) {
setVisible(false)
return
}
createEffect(() => {
if (!active()) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
})
})
return (
<FileAccordionItem
open={active()}
onOpenChange={(expanded) =>
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
}
type={file().type}
header={
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file().path, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file().path.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
return (
<FileAccordionItem
open={active()}
onOpenChange={(expanded) =>
change(expanded ? [...open(), value()] : open().filter((path) => path !== value()))
}
type={file().type}
header={
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file().path, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file().path.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${displayDirectory(file().path)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file().path)}</span>
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file().type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file().type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={true}>
<DiffChanges
appearance="standard"
changes={{ additions: file().additions, deletions: file().deletions }}
/>
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file().type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file().type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={true}>
<DiffChanges
appearance="standard"
changes={{ additions: file().additions, deletions: file().deletions }}
}
>
<Show when={props.deferContent === false || visible()}>
<For each={file().views}>
{(view) => (
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={view.fileDiff}
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
onRendered={props.onContentRendered}
/>
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
}
>
<Show when={props.deferContent === false || visible()}>
<For each={file().views}>
{(view) => (
<div data-component="apply-patch-file-diff">
<Dynamic
component={fileComponent}
mode="diff"
virtualize={props.virtualizeDiff}
fileDiff={view.fileDiff}
hunkSeparators={view.fileDiff.isPartial ? "simple" : "line-info-basic"}
onRendered={props.onContentRendered}
/>
</div>
)}
</For>
</Show>
</FileAccordionItem>
)
}}
</Index>
</FileAccordionGroup>
</Show>
</div>
)}
</For>
</Show>
</FileAccordionItem>
)
}}
</Index>
</FileAccordionGroup>
</Show>
</FileTool>
</div>
)
},
@@ -1,141 +0,0 @@
import type { SessionMessageAssistant } from "@opencode/client"
import { groupEntries, mergeGroups, splitGroups, type GroupNode } from "./tree"
export type PartRef = {
messageID: string
partID: string
}
export type CacheUsage = {
read: number
model: SessionMessageAssistant["model"]
}
export type SessionEntry =
| { type: "message"; messageID: string }
| { type: "compaction-queued"; inboxID: string }
| { type: "part"; ref: PartRef }
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
type GroupKind = "reasoning" | "exploration"
type SessionGroup = {
type: "group"
children: readonly GroupNode<SessionEntry, GroupKind>[]
size: number
completed: boolean
} & ({ kind: "reasoning" } | { kind: "exploration"; pending: PartRef[] })
export type SessionRow = SessionEntry | SessionGroup
export type AppendPart =
| { type: "text" }
| { type: "reasoning"; time?: { completed?: number } }
| { type: "tool"; name: string }
export type ProjectionEntry = {
entry: SessionEntry
part?: AppendPart
closesPrevious?: boolean
}
/** Hydrate a fresh history batch in one pass rather than merging one leaf at a time. */
export function projectEntries(entries: ProjectionEntry[]): SessionRow[] {
const nodes = groupEntries(entries, (item) => (item.part ? partPath(item.part) : []))
return nodes.map((node, index) => {
if (node.type === "entry") return node.entry.entry
const next = nodes[index + 1]
const completed =
(next !== undefined && (next.type === "group" || next.entry.closesPrevious !== false)) ||
(node.kind === "reasoning" &&
node.children.every(
(child) =>
child.type === "entry" &&
child.entry.part?.type === "reasoning" &&
child.entry.part.time?.completed !== undefined,
))
const group = { ...node, children: node.children.map(unwrap), completed }
return node.kind === "reasoning" ? { ...group, kind: "reasoning" } : { ...group, kind: "exploration", pending: [] }
})
}
function unwrap(node: GroupNode<ProjectionEntry, GroupKind>): GroupNode<SessionEntry, GroupKind> {
if (node.type === "entry") return { ...node, entry: node.entry.entry }
return { ...node, children: node.children.map(unwrap) }
}
function partPath(part: AppendPart): readonly GroupKind[] {
if (part.type === "reasoning") return ["reasoning"]
if (part.type === "tool" && ["read", "glob", "grep"].includes(part.name.toLowerCase())) return ["exploration"]
return []
}
/** Production rules only: keep lifecycle/status decisions outside the tree engine. */
export function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
const [node] = groupEntries<SessionEntry, GroupKind>([{ type: "part", ref }], () => partPath(part))
if (node.type === "entry") {
completePrevious(rows, index)
rows.splice(index, 0, node.entry)
return
}
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === node.kind) {
// Permission-blocked tools remain at the end, just as the former refs/pending
// partition did. Inserting a new ref must precede those blocked tools.
const pending = previous.kind === "exploration" ? previous.pending.length : 0
const [left, right] = splitGroups([previous], previous.size - pending)
const [merged] = mergeGroups(mergeGroups(left, [node]), right)
if (merged.type !== "group") throw new Error("Expected merged session group")
previous.children = merged.children
previous.size = merged.size
if (part.type === "reasoning") previous.completed &&= part.time?.completed !== undefined
return
}
completePrevious(rows, index)
rows.splice(
index,
0,
node.kind === "reasoning"
? { ...node, kind: "reasoning", completed: part.type === "reasoning" && part.time?.completed !== undefined }
: { ...node, kind: "exploration", pending: [], completed: false },
)
}
export function completePrevious(rows: SessionRow[], index = rows.length) {
const previous = rows[index - 1]
if (previous?.type === "group") previous.completed = true
}
/** Part references for an existing production subgroup, not a flat timeline. */
export function groupRefs(row: SessionGroup, includePending = false): PartRef[] {
const pending = !includePending && row.kind === "exploration" ? row.pending : []
const visit = (nodes: readonly GroupNode<SessionEntry, GroupKind>[]): PartRef[] =>
nodes.flatMap((node) => {
if (node.type === "group") return visit(node.children)
if (node.entry.type !== "part") return []
const ref = node.entry.ref
if (pending.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)) return []
return [ref]
})
return visit(row.children)
}
export function partitionPending(rows: SessionRow[], pending: Set<string>) {
rows.forEach((row) => {
if (row.type !== "group" || row.kind !== "exploration") return
// The production exploration rule creates direct part children. Preserve the
// existing stable partition order when permissions are admitted or dismissed.
const blocked = (node: GroupNode<SessionEntry, GroupKind>) =>
node.type === "entry" && node.entry.type === "part" && pending.has(node.entry.ref.partID)
row.children = [...row.children.filter((node) => !blocked(node)), ...row.children.filter(blocked)]
row.pending = groupRefs(row, true).filter((ref) => pending.has(ref.partID))
})
}
export function hasPart(rows: SessionRow[], ref: PartRef) {
return rows.some((row) => {
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
if (row.type !== "group") return false
return groupRefs(row, true).some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
})
}
@@ -1,111 +0,0 @@
export type GroupNode<Entry, Kind extends string> =
| { readonly type: "entry"; readonly entry: Entry; readonly size: 1 }
| {
readonly type: "group"
readonly kind: Kind
readonly children: readonly GroupNode<Entry, Kind>[]
/** Number of descendant leaves, independent of disclosure state. */
readonly size: number
}
/**
* Group adjacent entries by their configured nesting paths. For example, a read
* can use ["exploration"] today or ["activity", "exploration"] in Low.
* Entries are opaque: message/part identity, visibility and live state remain
* owned by the session projection. A path of [] creates a standalone leaf.
*/
export function groupEntries<Entry, Kind extends string>(
entries: readonly Entry[],
path: (entry: Entry) => readonly Kind[],
): readonly GroupNode<Entry, Kind>[] {
const result: BuildingNode<Entry, Kind>[] = []
entries.forEach((entry) => {
appendEntry(result, entry, path(entry))
})
return result
}
// Only freshly constructed nodes are writable; the published tree is readonly.
type BuildingNode<Entry, Kind extends string> =
| { type: "entry"; entry: Entry; size: 1 }
| { type: "group"; kind: Kind; children: BuildingNode<Entry, Kind>[]; size: number }
function appendEntry<Entry, Kind extends string>(
nodes: BuildingNode<Entry, Kind>[],
entry: Entry,
path: readonly Kind[],
depth = 0,
) {
const kind = path[depth]
if (kind === undefined) {
nodes.push({ type: "entry", entry, size: 1 })
return
}
const previous = nodes.at(-1)
if (previous?.type === "group" && previous.kind === kind) {
previous.size++
appendEntry(previous.children, entry, path, depth + 1)
return
}
const children: BuildingNode<Entry, Kind>[] = []
appendEntry(children, entry, path, depth + 1)
nodes.push({ type: "group", kind, children, size: 1 })
}
/**
* Concatenate ordered, disjoint chunks, recursively merging compatible groups
* at their seam. Untouched subtrees retain their object identity.
*
* This is concatenation, not ingestion: callers must reconcile overlapping
* pages/replayed message IDs before merging. Equal payloads may be distinct
* entries and must not be silently deduplicated here.
*/
export function mergeGroups<Entry, Kind extends string>(
left: readonly GroupNode<Entry, Kind>[],
right: readonly GroupNode<Entry, Kind>[],
): readonly GroupNode<Entry, Kind>[] {
if (!left.length) return right
if (!right.length) return left
const a = left[left.length - 1]
const b = right[0]
if (a.type !== "group" || b.type !== "group" || a.kind !== b.kind) return [...left, ...right]
return [
...left.slice(0, -1),
{
type: "group",
kind: a.kind,
size: a.size + b.size,
children: mergeGroups(a.children, b.children),
},
...right.slice(1),
]
}
/**
* Split at a depth-first leaf offset. Group headers count as zero. Cached sizes
* skip whole subtrees; only the ancestors crossing the cut are reconstructed.
* The returned halves can be seam-merged again without changing their meaning.
*/
export function splitGroups<Entry, Kind extends string>(
nodes: readonly GroupNode<Entry, Kind>[],
count: number,
): readonly [readonly GroupNode<Entry, Kind>[], readonly GroupNode<Entry, Kind>[]] {
if (!Number.isInteger(count) || count < 0) throw new RangeError("Group split requires a non-negative integer")
if (count === 0) return [[], nodes]
let offset = 0
for (const [index, node] of nodes.entries()) {
const end = offset + node.size
if (count === end) return [nodes.slice(0, index + 1), nodes.slice(index + 1)]
if (count < end) {
if (node.type !== "group") throw new RangeError("Cannot split inside an entry")
const size = count - offset
const [left, right] = splitGroups(node.children, size)
return [
[...nodes.slice(0, index), { ...node, children: left, size }],
[{ ...node, children: right, size: node.size - size }, ...nodes.slice(index + 1)],
]
}
offset = end
}
throw new RangeError("Group split exceeds entry count")
}
+2 -5
View File
@@ -110,7 +110,6 @@ import { isRecord } from "../../util/record"
import { createHistoryPrepend } from "./history"
import { context, use, type PendingAction } from "./render-context"
import { INLINE_TOOL_ICON_WIDTH, InlineToolRow, ReasoningPart, reasoningContent, TextPart } from "./message-parts"
import { groupRefs } from "./grouping/session"
export { InlineToolRow } from "./message-parts"
addDefaultParsers(parsers.parsers)
@@ -1443,14 +1442,12 @@ function SessionRowView(props: SessionRowViewProps) {
{(row) => <SessionPartView partRef={row().ref} message={props.message} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "reasoning" ? props.row : undefined}>
{(row) => (
<SessionReasoningGroupView refs={groupRefs(row())} completed={row().completed} message={props.message} />
)}
{(row) => <SessionReasoningGroupView refs={row().refs} completed={row().completed} message={props.message} />}
</Match>
<Match when={props.row.type === "group" && props.row.kind === "exploration" ? props.row : undefined}>
{(row) => (
<SessionGroupView
refs={groupRefs(row())}
refs={row().refs}
pending={row().pending}
completed={row().completed}
message={props.message}
+105 -27
View File
@@ -4,20 +4,36 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { useConfig } from "../../config"
import { useData } from "../../context/data"
import { useClient } from "../../context/client"
import {
append,
completePrevious,
groupRefs,
hasPart,
partitionPending,
projectEntries,
type AppendPart,
type CacheUsage,
type PartRef,
type ProjectionEntry,
type SessionRow,
} from "./grouping/session"
export type { CacheUsage, PartRef, SessionRow } from "./grouping/session"
export type PartRef = {
messageID: string
partID: string
}
export type CacheUsage = {
read: number
model: SessionMessageAssistant["model"]
}
export type SessionRow =
| { type: "message"; messageID: string }
| { type: "compaction-queued"; inboxID: string }
| { type: "part"; ref: PartRef }
| {
type: "group"
kind: "reasoning"
refs: PartRef[]
completed: boolean
}
| {
type: "group"
kind: "exploration"
refs: PartRef[]
pending: PartRef[]
completed: boolean
}
| { type: "assistant-footer"; messageID: string }
| { type: "turn-usage"; messageIDs: string[]; previousCache?: CacheUsage }
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
const data = useData()
@@ -164,7 +180,7 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
(row) =>
row.type === "group" &&
row.kind === "reasoning" &&
groupRefs(row).some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID),
)
if (row?.type === "group" && row.kind === "reasoning") row.completed = true
}),
@@ -283,15 +299,16 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
const usage = turnTokens
? { steps: [] as SessionMessageAssistant[], previousTurnCache: undefined as CacheUsage | undefined }
: undefined
const entries = [
return [
...messages.filter((message) => !pending.has(message.id)),
...pendingCompactions,
...messages.filter(isInput),
].reduce<ProjectionEntry[]>((rows, message) => {
].reduce<SessionRow[]>((rows, message) => {
if (message.type !== "assistant") {
if (message.type === "synthetic" && !message.description?.trim()) return rows
if (message.type === "compaction" && message.status === "completed" && usage) usage.previousTurnCache = undefined
rows.push({ entry: { type: "message", messageID: message.id }, closesPrevious: !pending.has(message.id) })
if (!pending.has(message.id)) completePrevious(rows)
rows.push({ type: "message", messageID: message.id })
return rows
}
usage?.steps.push(message)
@@ -299,22 +316,21 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
message.content.forEach((part) => {
const partID = part.type === "tool" ? part.id : `${part.type}:${ordinals[part.type]++}`
if ((part.type === "text" || part.type === "reasoning") && !part.text.trim()) return
rows.push({ entry: { type: "part", ref: { messageID: message.id, partID } }, part })
append(rows, { messageID: message.id, partID }, part)
})
const terminal = (message.finish && !["tool-calls", "unknown"].includes(message.finish)) || message.error
if (terminal || message.retry) {
rows.push({ entry: { type: "assistant-footer", messageID: message.id } })
completePrevious(rows)
rows.push({ type: "assistant-footer", messageID: message.id })
}
if (terminal && usage) {
const stepsWithUsage = usage.steps.filter(hasTokenUsage)
const last = stepsWithUsage.at(-1)
if (last) {
rows.push({
entry: {
type: "turn-usage",
messageIDs: stepsWithUsage.map((step) => step.id),
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
},
type: "turn-usage",
messageIDs: stepsWithUsage.map((step) => step.id),
...(usage.previousTurnCache === undefined ? {} : { previousCache: usage.previousTurnCache }),
})
usage.previousTurnCache = { read: last.tokens.cache.read, model: last.model }
}
@@ -322,7 +338,6 @@ export function reduceSessionRows(messages: SessionMessageInfo[], inputs = new S
}
return rows
}, [])
return projectEntries(entries)
}
export function cacheReuseDrop(previous: CacheUsage | undefined, current: CacheUsage) {
@@ -412,7 +427,7 @@ function rowBoundaryMessageID(row: SessionRow, messages: Map<string, SessionMess
row.type === "part"
? row.ref.messageID
: row.type === "group"
? groupRefs(row)[0]?.messageID
? row.refs[0]?.messageID
: row.type === "assistant-footer"
? row.messageID
: row.type === "turn-usage"
@@ -431,3 +446,66 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) {
const ordinal = Number(match[2])
return message.content.filter((part) => part.type === match[1])[ordinal]
}
type AppendPart =
| { type: "text" }
| { type: "reasoning"; time?: { completed?: number } }
| { type: "tool"; name: string }
function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) {
if (part.type === "reasoning") {
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === "reasoning") {
previous.refs.push(ref)
previous.completed &&= part.time?.completed !== undefined
return
}
completePrevious(rows, index)
rows.splice(index, 0, {
type: "group",
kind: "reasoning",
refs: [ref],
completed: part.time?.completed !== undefined,
})
return
}
if (part.type === "tool" && exploration(part.name)) {
const previous = rows[index - 1]
if (previous?.type === "group" && previous.kind === "exploration") {
previous.refs.push(ref)
return
}
completePrevious(rows, index)
rows.splice(index, 0, { type: "group", kind: "exploration", refs: [ref], pending: [], completed: false })
return
}
completePrevious(rows, index)
rows.splice(index, 0, { type: "part", ref })
}
function completePrevious(rows: SessionRow[], index = rows.length) {
const previous = rows[index - 1]
if (previous?.type === "group") previous.completed = true
}
function partitionPending(rows: SessionRow[], pending: Set<string>) {
rows.forEach((row) => {
if (row.type !== "group" || row.kind !== "exploration") return
const refs = [...row.refs, ...row.pending]
row.refs = refs.filter((ref) => !pending.has(ref.partID))
row.pending = refs.filter((ref) => pending.has(ref.partID))
})
}
function exploration(name: string) {
return ["read", "glob", "grep"].includes(name.toLowerCase())
}
function hasPart(rows: SessionRow[], ref: PartRef) {
return rows.some((row) => {
if (row.type === "part") return row.ref.messageID === ref.messageID && row.ref.partID === ref.partID
if (row.type !== "group") return false
const refs = row.kind === "exploration" ? [...row.refs, ...row.pending] : row.refs
return refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID)
})
}
@@ -1,82 +0,0 @@
import { expect, test } from "bun:test"
import { groupEntries, mergeGroups, splitGroups } from "../../../src/routes/session/grouping/tree"
const path = (entry: { path: string[] }) => entry.path
const read = { id: "read", path: ["activity", "exploration"] }
const search = { id: "search", path: ["activity", "exploration"] }
const thought = { id: "thought", path: ["activity", "reasoning"] }
const text = { id: "text", path: [] }
const leaf = (entry: typeof read) => ({ type: "entry" as const, entry, size: 1 as const })
test("groups adjacent entries by path and counts leaves, not wrappers", () => {
expect(groupEntries([read, search, thought, text, read], path)).toEqual([
{
type: "group",
kind: "activity",
size: 3,
children: [
{ type: "group", kind: "exploration", size: 2, children: [leaf(read), leaf(search)] },
{ type: "group", kind: "reasoning", size: 1, children: [leaf(thought)] },
],
},
leaf(text),
{
type: "group",
kind: "activity",
size: 1,
children: [{ type: "group", kind: "exploration", size: 1, children: [leaf(read)] }],
},
])
})
test("a direct child breaks a subgroup without ending the outer group", () => {
const shell = { id: "shell", path: ["activity"] }
expect(groupEntries([read, shell, search], path)).toEqual([
{
type: "group",
kind: "activity",
size: 3,
children: [
{ type: "group", kind: "exploration", size: 1, children: [leaf(read)] },
leaf(shell),
{ type: "group", kind: "exploration", size: 1, children: [leaf(search)] },
],
},
])
})
test("merges both grouping levels at a page seam without changing the inputs", () => {
const left = groupEntries([text, read], path)
const right = groupEntries([search, thought], path)
const saved = structuredClone([left, right])
const merged = mergeGroups(left, right)
expect(merged).toEqual(groupEntries([text, read, search, thought], path))
expect([left, right]).toEqual(saved)
expect(merged[0]).toBe(left[0])
})
test("splits at each leaf boundary and merges back to the original tree", () => {
const entries = [read, search, thought, text]
const tree = groupEntries(entries, path)
for (let count = 0; count <= entries.length; count++) {
const [left, right] = splitGroups(tree, count)
expect(left).toEqual(groupEntries(entries.slice(0, count), path))
expect(right).toEqual(groupEntries(entries.slice(count), path))
expect(mergeGroups(left, right)).toEqual(tree)
}
})
test("handles empty chunks", () => {
const tree = groupEntries([read], path)
expect(groupEntries([], path)).toEqual([])
expect(mergeGroups([], tree)).toBe(tree)
expect(mergeGroups(tree, [])).toBe(tree)
expect(splitGroups([], 0)).toEqual([[], []])
})
test("rejects invalid split offsets", () => {
const tree = groupEntries([read], path)
for (const count of [-1, 0.5, 2, NaN]) {
expect(() => splitGroups(tree, count)).toThrow(RangeError)
}
})
@@ -1,21 +0,0 @@
import { expect, test } from "bun:test"
import { append, groupRefs, partitionPending, type SessionRow } from "../../../src/routes/session/grouping/session"
test("a pending tool does not hide a later tool reusing its call ID in another message", () => {
const rows: SessionRow[] = []
const blocked = { messageID: "assistant-a", partID: "call-reused" }
const later = { messageID: "assistant-b", partID: "call-reused" }
append(rows, blocked, { type: "tool", name: "read" })
partitionPending(rows, new Set([blocked.partID]))
append(rows, later, { type: "tool", name: "read" })
const group = rows[0]
if (group.type !== "group" || group.kind !== "exploration") throw new Error("Expected exploration group")
expect(groupRefs(group)).toEqual([later])
expect(group.pending).toEqual([blocked])
expect(groupRefs(group, true)).toEqual([later, blocked])
partitionPending(rows, new Set())
expect(groupRefs(group)).toEqual([later, blocked])
expect(group.pending).toEqual([])
})
+26 -35
View File
@@ -280,11 +280,10 @@ test("groups exploration parts across assistant messages until a delimiter", ()
kind: "exploration",
pending: [],
completed: true,
size: 3,
children: [
partChild("assistant-1", "read-1"),
partChild("assistant-1", "glob-1"),
partChild("assistant-2", "grep-1"),
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-1", partID: "glob-1" },
{ messageID: "assistant-2", partID: "grep-1" },
],
},
{ type: "part", ref: { messageID: "assistant-2", partID: "text:0" } },
@@ -306,8 +305,7 @@ test("keeps non-exploration tools as individual part rows", () => {
kind: "exploration",
pending: [],
completed: true,
size: 1,
children: [partChild("assistant-1", "read-1")],
refs: [{ messageID: "assistant-1", partID: "read-1" }],
},
{ type: "part", ref: { messageID: "assistant-1", partID: "reasoning:0" } },
{
@@ -315,8 +313,7 @@ test("keeps non-exploration tools as individual part rows", () => {
kind: "exploration",
pending: [],
completed: false,
size: 1,
children: [partChild("assistant-1", "grep-1")],
refs: [{ messageID: "assistant-1", partID: "grep-1" }],
},
])
})
@@ -337,16 +334,14 @@ test("assigns stable kind ordinals within an assistant message", () => {
type: "group",
kind: "reasoning",
completed: true,
size: 1,
children: [partChild("assistant-1", "reasoning:0")],
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
},
{ type: "part", ref: { messageID: "assistant-1", partID: "text:1" } },
{
type: "group",
kind: "reasoning",
completed: false,
size: 1,
children: [partChild("assistant-1", "reasoning:1")],
refs: [{ messageID: "assistant-1", partID: "reasoning:1" }],
},
])
})
@@ -366,16 +361,17 @@ test("groups adjacent reasoning parts until a visible boundary", () => {
type: "group",
kind: "reasoning",
completed: true,
size: 2,
children: [partChild("assistant-1", "reasoning:0"), partChild("assistant-1", "reasoning:1")],
refs: [
{ messageID: "assistant-1", partID: "reasoning:0" },
{ messageID: "assistant-1", partID: "reasoning:1" },
],
},
{ type: "part", ref: { messageID: "assistant-1", partID: "text:0" } },
{
type: "group",
kind: "reasoning",
completed: false,
size: 1,
children: [partChild("assistant-1", "reasoning:2")],
refs: [{ messageID: "assistant-1", partID: "reasoning:2" }],
},
])
})
@@ -397,16 +393,17 @@ test("groups across empty assistant reasoning parts", () => {
type: "group",
kind: "reasoning",
completed: true,
size: 1,
children: [partChild("assistant-1", "reasoning:0")],
refs: [{ messageID: "assistant-1", partID: "reasoning:0" }],
},
{
type: "group",
kind: "exploration",
pending: [],
completed: false,
size: 2,
children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")],
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-2", partID: "grep-1" },
],
},
])
})
@@ -428,8 +425,7 @@ test("completes exploration groups when another row follows", () => {
kind: "exploration",
pending: [],
completed: true,
size: 1,
children: [partChild("assistant-1", "read-1")],
refs: [{ messageID: "assistant-1", partID: "read-1" }],
},
{ type: "message", messageID: "user-1" },
{
@@ -437,8 +433,7 @@ test("completes exploration groups when another row follows", () => {
kind: "exploration",
pending: [],
completed: true,
size: 1,
children: [partChild("assistant-2", "grep-1")],
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
},
{ type: "assistant-footer", messageID: "assistant-2" },
])
@@ -473,8 +468,10 @@ test("hides synthetic messages without descriptions", () => {
kind: "exploration",
pending: [],
completed: false,
size: 2,
children: [partChild("assistant-1", "read-1"), partChild("assistant-2", "grep-1")],
refs: [
{ messageID: "assistant-1", partID: "read-1" },
{ messageID: "assistant-2", partID: "grep-1" },
],
},
])
expect(reduceSessionRows(messages, new Set(["synthetic-1"]))).toEqual(rows)
@@ -499,8 +496,7 @@ test("renders synthetic messages with descriptions", () => {
kind: "exploration",
pending: [],
completed: true,
size: 1,
children: [partChild("assistant-1", "read-1")],
refs: [{ messageID: "assistant-1", partID: "read-1" }],
},
{ type: "message", messageID: "synthetic-1" },
{
@@ -508,16 +504,11 @@ test("renders synthetic messages with descriptions", () => {
kind: "exploration",
pending: [],
completed: false,
size: 1,
children: [partChild("assistant-2", "grep-1")],
refs: [{ messageID: "assistant-2", partID: "grep-1" }],
},
])
})
function partChild(messageID: string, partID: string) {
return { type: "entry" as const, entry: { type: "part" as const, ref: { messageID, partID } }, size: 1 as const }
}
test("renders a footer for a pre-output retry assistant after replay", () => {
const message = assistant("assistant-retry", [])
message.retry = {
@@ -0,0 +1,25 @@
export * as ProviderPolicyMatcher from "./opencode-policy-matcher.js"
// Shared verbatim with OpenCode packages/util/src/opencode-policy-matcher.ts.
// Keep the paired conformance check passing when changing these semantics.
export type Statement = {
readonly action: "provider.use"
readonly resource: string
readonly effect: "allow" | "deny"
}
export function match(input: string, pattern: string, windows: boolean) {
const normalized = input.replaceAll("\\", "/")
const escaped = pattern
.replaceAll("\\", "/")
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
.replace(/\*/g, ".*")
.replace(/\?/g, ".")
const expression = escaped.endsWith(" .*") ? escaped.slice(0, -3) + "( .*)?" : escaped
return new RegExp("^" + expression + "$", windows ? "si" : "s").test(normalized)
}
export function evaluate(statements: readonly Statement[], resource: string, windows: boolean) {
const index = statements.findLastIndex((statement) => match(resource, statement.resource, windows))
return { effect: index === -1 ? ("inherit" as const) : statements[index]!.effect, index: index === -1 ? null : index }
}
@@ -0,0 +1,146 @@
[
{
"name": "no organization override",
"rules": [],
"resource": "openai",
"unix": "inherit",
"windows": "inherit",
"index": null
},
{
"name": "last match wins",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "*"
},
{
"action": "provider.use",
"effect": "allow",
"resource": "openai"
}
],
"resource": "openai",
"unix": "allow",
"windows": "allow",
"index": 1
},
{
"name": "wildcard after exact wins",
"rules": [
{
"action": "provider.use",
"effect": "allow",
"resource": "openai"
},
{
"action": "provider.use",
"effect": "deny",
"resource": "*"
}
],
"resource": "openai",
"unix": "deny",
"windows": "deny",
"index": 1
},
{
"name": "anchored wildcard",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "company-*"
}
],
"resource": "not-company-a",
"unix": "inherit",
"windows": "inherit",
"index": null
},
{
"name": "question matches one",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "company-?"
}
],
"resource": "company-ab",
"unix": "inherit",
"windows": "inherit",
"index": null
},
{
"name": "slash normalization",
"rules": [
{
"action": "provider.use",
"effect": "allow",
"resource": "company/*"
}
],
"resource": "company\\model",
"unix": "allow",
"windows": "allow",
"index": 0
},
{
"name": "case differs by platform",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "OpenAI"
}
],
"resource": "openai",
"unix": "inherit",
"windows": "deny",
"index": 0
},
{
"name": "regex characters are literal",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "company.[ab]"
}
],
"resource": "company.a",
"unix": "inherit",
"windows": "inherit",
"index": null
},
{
"name": "trailing wildcard space",
"rules": [
{
"action": "provider.use",
"effect": "allow",
"resource": "company *"
}
],
"resource": "company",
"unix": "allow",
"windows": "allow",
"index": 0
},
{
"name": "question matches exactly one",
"rules": [
{
"action": "provider.use",
"effect": "deny",
"resource": "comp?ny"
}
],
"resource": "company",
"unix": "deny",
"windows": "deny",
"index": 0
}
]
@@ -0,0 +1,34 @@
import { expect, it } from "bun:test"
import { Schema } from "effect"
import { ProviderPolicyMatcher } from "../src/opencode-policy-matcher.js"
const cases = Schema.decodeUnknownSync(
Schema.Array(
Schema.Struct({
name: Schema.String,
rules: Schema.Array(
Schema.Struct({
action: Schema.Literal("provider.use"),
effect: Schema.Literals(["allow", "deny"]),
resource: Schema.String,
}),
),
resource: Schema.String,
unix: Schema.Literals(["allow", "deny", "inherit"]),
windows: Schema.Literals(["allow", "deny", "inherit"]),
index: Schema.NullOr(Schema.Int),
}),
),
)(await Bun.file(new URL("./opencode-policy-cases.json", import.meta.url)).json())
for (const scenario of cases) {
it(scenario.name, () => {
for (const windows of [false, true]) {
const effect = windows ? scenario.windows : scenario.unix
expect(ProviderPolicyMatcher.evaluate(scenario.rules, scenario.resource, windows)).toEqual({
effect,
index: effect === "inherit" ? null : scenario.index,
})
}
})
}
+167 -240
View File
@@ -2,13 +2,85 @@
title: "Agents"
---
Create a Markdown file to add a reusable agent. This example adds a read-only reviewer that the main agent can launch for code reviews:
Agents combine a system prompt, model preference, tool permissions, and display
metadata into a reusable assistant profile. OpenCode includes agents for common
workflows, and you can override them or add your own in configuration or
Markdown files.
## Built-in agents
| Agent | Mode | Purpose |
| ----------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Build** (`build`) | `primary` | Default coding agent. Tools are allowed by default, sensitive environment-file reads ask for approval, and access outside the workspace asks for approval. |
| **Plan** (`plan`) | `primary` | Planning agent. File edits are denied except for OpenCode plan files. Shell commands are not generally denied. |
| **General** (`general`) | `subagent` | General-purpose research and multi-step work. It has broad tool access but cannot launch more subagents. |
| **Explore** (`explore`) | `subagent` | Read-only code and web exploration using `read`, `glob`, `grep`, `webfetch`, and `websearch`. |
OpenCode also has hidden `compaction`, `title`, and `summary` system agents.
They run internal maintenance tasks and are not available for direct use. There is no built-in
`scout` agent in V2.
You can override a built-in agent with an entry of the same ID. Set
`disabled: true` to remove one.
## Default agent
Set the primary agent used when a session has not selected one:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "reviewer",
}
```
The configured agent must exist, must not have `mode: "subagent"`, and must not
be hidden. If it is unavailable, OpenCode falls back to `build`, then to the
first visible agent that can run as a primary agent. This selection does not
rewrite the agent already stored on an existing session.
## Modes
An agent's `mode` controls where it can run:
| Mode | Behavior |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primary` | Can be selected as the main agent for a session. It cannot be launched as a subagent. This is the default for a custom agent when `mode` is omitted. |
| `subagent` | Can run in a child session through the `subagent` tool, but cannot be selected as the main agent. |
| `all` | Can be used either way. |
Subagents run in child sessions with fresh context. A primary agent can invoke
one with the `subagent` tool, either in the foreground or in the background.
The parent agent's `subagent` permission controls which agents it may launch.
The child currently uses its own configured permissions, not a restricted copy
of the parent's permissions.
## Configure agents
### Markdown files
The recommended file locations are:
```text
~/.config/opencode/agents/<name>.md
.opencode/agents/<name>.md
```
OpenCode discovers project `.opencode` directories from the current directory
up to the project root. The path below `agents/` becomes the agent ID, so
`.opencode/agents/team/reviewer.md` defines `team/reviewer`.
Frontmatter uses the same fields as an entry under `agents`. The Markdown body
becomes `system`:
```md title=".opencode/agents/reviewer.md"
---
description: Reviews changes for correctness and regressions
description: Reviews changes without modifying files
mode: subagent
model: anthropic/claude-sonnet-4-5#high
color: "#ff6b6b"
steps: 8
permissions:
- action: edit
resource: "*"
@@ -18,191 +90,70 @@ permissions:
effect: deny
---
Review the current changes. List findings in severity order with file and line references.
Review for correctness, security, regressions, and missing tests.
List findings in severity order with file and line references.
```
Ask your primary agent to use it:
### JSON or JSONC
```text
Use the reviewer subagent to review my current changes.
```
An agent combines a system prompt, model preference, permissions, and display details into a named assistant profile.
## Locations
Save Markdown agents globally for all projects or inside a project:
```text
~/.config/opencode/agents/<name>.md
.opencode/agents/<name>.md
```
OpenCode discovers project `.opencode` directories from the current directory up to the project root. A nested path becomes part of the agent ID:
```text
.opencode/agents/team/reviewer.md → team/reviewer
```
## Formats
### Markdown
Frontmatter accepts the same fields as an `agents` configuration entry. The Markdown body becomes the agent's `system` prompt:
```md title=".opencode/agents/explainer.md"
---
description: Explains code without changing it
mode: subagent
---
Explain the relevant code with short examples. Do not edit files.
```
### JSONC
Define agents under `agents` in any [OpenCode configuration file](/config):
Use the `agents` field in any [OpenCode configuration file](/config):
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "reviewer",
"agents": {
"reviewer": {
"description": "Reviews current changes",
"mode": "subagent",
"system": "Report findings in severity order.",
"description": "Reviews changes for correctness, security, and missing tests",
"mode": "all",
"model": "anthropic/claude-sonnet-4-5#high",
"system": "Review the current changes. Report findings before any summary.",
"color": "#ff6b6b",
"steps": 8,
"permissions": [
{ "action": "edit", "resource": "*", "effect": "deny" },
{ "action": "shell", "resource": "*", "effect": "deny" },
],
},
},
}
```
## Selection
Set the primary agent used when a session has not selected one:
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"default_agent": "writer",
"agents": {
"writer": { "mode": "primary" },
},
}
```
The selected default must exist, be visible, and support primary use. Otherwise OpenCode uses `build`, then the first visible primary-capable agent. Changing this setting does not replace the agent stored on an existing session.
## Modes
Set `mode` according to where the agent should run:
```jsonc
{
"agents": {
"reviewer": { "mode": "subagent" },
},
}
```
| Mode | Behavior |
| --- | --- |
| `primary` | Runs as the main agent for a session. This is the default for a new custom agent. |
| `subagent` | Runs only in a child session through the `subagent` tool. |
| `all` | Runs either as a primary agent or a subagent. |
Subagents run with fresh context in foreground or background child sessions. The parent agent's `subagent` permissions control which agents it may launch; the child uses its own configured permissions.
```jsonc
{
"agents": {
"orchestrator": {
"permissions": [
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "reviewer", "effect": "allow" },
],
},
},
}
```
## Builtins
OpenCode includes these visible agents:
| Agent | Mode | Purpose |
| --- | --- | --- |
| **Build** (`build`) | `primary` | Default coding agent. Tools are allowed by default; sensitive environment-file reads and access outside the workspace ask for approval. |
| **Plan** (`plan`) | `primary` | Explores and plans without editing normal project files. It may write OpenCode plan files when asked, and shell commands remain permission-controlled. |
| **General** (`general`) | `subagent` | Handles research and multi-step work with broad tool access, but cannot launch more subagents. |
| **Explore** (`explore`) | `subagent` | Searches and reads code or web sources without editing files. |
Override a built-in by using the same ID:
```jsonc
{
"agents": {
"build": {
"permissions": [
{ "action": "shell", "resource": "git push *", "effect": "ask" },
],
"permissions": [{ "action": "shell", "resource": "git push *", "effect": "ask" }],
},
},
}
```
Hidden `compaction`, `title`, and `summary` agents perform maintenance and cannot be selected directly. V2 has no built-in `scout` agent.
## Merging
Agent definitions merge in configuration order. Later scalar values replace earlier values, request maps merge by key, and permission rules append:
```jsonc
{
"permissions": [
{ "action": "shell", "resource": "*", "effect": "ask" },
],
"agents": {
"build": {
"permissions": [
{ "action": "shell", "resource": "git status", "effect": "allow" },
],
},
},
}
```
Global `permissions` apply before agent-specific rules, so later agent rules can refine them.
Agent definitions merge in configuration order. Later scalar fields replace
earlier values, request maps merge by key, and permission rules are appended.
Global `permissions` are applied to every agent before its agent-specific rules,
so a later agent rule can refine a global rule.
## Options
### Description
### `description`
`description` explains the agent's purpose. Add it to subagents because OpenCode shows it to the model choosing which agent to launch:
Explains the agent's purpose. It is optional, but strongly recommended for
subagents because OpenCode includes it in the subagent catalog shown to the
model.
```yaml
description: Reviews database migrations for safety
### `mode`
Accepts `primary`, `subagent`, or `all`. The default is `all`.
### `model`
Selects a model using `provider/model` with an optional `#variant`:
```jsonc
{
"agents": {
"reviewer": {
"model": "anthropic/claude-sonnet-4-5#high",
},
},
}
```
### Mode
`mode` accepts `primary`, `subagent`, or `all`. When omitted on a new custom agent, it defaults to `primary`:
```yaml
mode: all
```
### Model
`model` uses `provider/model` with an optional `#variant`:
```yaml
model: anthropic/claude-sonnet-4-5#high
```
JSON configuration also accepts the expanded form:
The equivalent expanded form is:
```jsonc
{
@@ -218,108 +169,84 @@ JSON configuration also accepts the expanded form:
}
```
- A subagent uses its configured model, or inherits the parent session's model when none is configured.
- A session stores its selected model separately. Selecting a primary agent by ID does not change that model.
This is the preferred model when the agent is activated. A child session uses
its subagent's configured model, or inherits the parent session's model when
none is configured. The session's selected model is stored separately;
creating or switching a primary session with only an agent ID does not itself
change that session model.
### System
### `system`
`system` sets the agent's system prompt. A non-empty value replaces the provider's base prompt for that agent:
Sets the agent's system prompt. A non-empty value replaces OpenCode's
provider-specific base prompt for that agent. Project instructions, skills,
references, and other instruction sources are still added separately.
For a Markdown agent, use the document body instead of a `system` frontmatter
field.
### `permissions`
Permissions are an ordered array of rules:
```jsonc
{
"agents": {
"reviewer": { "system": "Review only. Do not modify files." },
},
}
```
Project instructions, skills, references, and other instruction sources are still added. In a Markdown agent, put this text in the document body instead of a `system` frontmatter field.
### Permissions
`permissions` is an ordered list of matching rules:
```jsonc
{
"agents": {
"reviewer": {
"orchestrator": {
"permissions": [
{ "action": "*", "resource": "*", "effect": "deny" },
{ "action": "read", "resource": "src/**", "effect": "allow" },
{ "action": "subagent", "resource": "*", "effect": "deny" },
{ "action": "subagent", "resource": "explore", "effect": "allow" },
{ "action": "shell", "resource": "git *", "effect": "ask" },
],
},
},
}
```
| Field | Meaning |
| --- | --- |
| `action` | Tool or permission action. Wildcards are supported. |
| `resource` | Path, command, agent ID, or other value matched by the action. Wildcards are supported. |
| `effect` | `allow`, `ask`, or `deny`. |
Each rule has:
The last matching rule wins, so put broad rules before exceptions. Common actions include:
| Field | Meaning |
| ---------- | ---------------------------------------------------------------------------------------------- |
| `action` | Tool or permission action, with `*` wildcards supported. |
| `resource` | The path, command, agent ID, or other resource matched by the action. Wildcards are supported. |
| `effect` | `allow`, `ask`, or `deny`. |
| Action | Covers |
| --- | --- |
| `shell` | Shell commands |
| `edit` | Edit, write, and patch tools |
| `subagent` | Child agents |
| `read`, `glob`, `grep` | Local discovery tools |
| `webfetch`, `websearch` | Web tools |
| `skill` | Skill loading |
The last matching rule wins. Important V2 action names include `shell` for
shell commands, `edit` for all edit/write/patch tools, and `subagent` for child
agents. Other tools generally use their tool name, such as `read`, `glob`,
`grep`, `webfetch`, `websearch`, and `skill`.
For `read`, `edit`, and `external_directory` resources, OpenCode expands `~` and `$HOME`:
<Callout type="tip">
Put broad wildcard rules first and exceptions afterward. For example, deny all subagents first, then allow `explore`.
</Callout>
```jsonc
{ "action": "read", "resource": "~/notes/**", "effect": "allow" }
```
`~` and `$HOME` are expanded in filesystem resources for `read`, `edit`, and
`external_directory`. Shell resources are raw command text and are not
expanded.
Shell resources remain raw command text and do not expand those values.
### `steps`
### Steps
Sets a positive maximum number of model steps. On the final allowed step,
OpenCode removes tools and asks the model to summarize its work in text. New
user input resets the allowance.
`steps` sets a positive maximum number of model steps:
### `hidden`
```yaml
steps: 8
```
When `true`, removes the agent from normal agent listings, interactive
discovery, and the subagent catalog advertised to models. It is a visibility
setting, not a security boundary.
On the final step, OpenCode removes tools and asks the model to summarize in text. New user input resets the allowance.
### `color`
### Hidden
Sets the agent's UI color. Use a six-digit hex color such as `#ff6b6b`.
`hidden` removes an agent from normal listings, interactive discovery, and the subagent catalog:
### `disabled`
```yaml
hidden: true
```
When `true`, removes the agent definition at that point in configuration
loading. This works for built-in and custom agents.
This controls visibility, not security. Use permissions to restrict behavior.
### `request`
### Color
`color` sets the agent's UI color using a six-digit hex value:
```yaml
color: "#ff6b6b"
```
### Disabled
`disabled` removes a built-in or custom agent at that point in configuration loading:
```jsonc
{
"agents": {
"plan": { "disabled": true },
},
}
```
### Request
`request` accepts per-agent header and JSON body overlays:
The V2 schema accepts per-agent request `headers` and JSON `body` overlays:
```jsonc
{
@@ -335,8 +262,8 @@ color: "#ff6b6b"
```
<Callout type="warning">
The V2 session runner preserves these values but does not yet send them with model requests. Configure active request
settings on the provider, model, or model variant instead.
The current V2 session runner preserves these overlays on the agent definition but does not yet apply them to model
requests. Configure effective request settings on the provider, model, or model variant instead. Do not use legacy
top-level agent fields such as `temperature`, `top_p`, `prompt`, `permission`, `tools`, `disable`, or `maxSteps` in
new V2 configuration.
</Callout>
Do not use legacy top-level fields such as `temperature`, `top_p`, `prompt`, `permission`, `tools`, `disable`, or `maxSteps` in new V2 agent configuration.
+63 -118
View File
@@ -2,78 +2,52 @@
title: "Attachments"
---
## Attach
Attachments add local context to a prompt as text or image media. Regardless
of how a prompt is submitted, current V2 sessions make these attachment types
visible to the model:
Attach a local file, then ask OpenCode to use it in your prompt. In the desktop
or web client, choose **Attach file**, paste a file, or drag it into the prompt.
| Input | Model receives |
| ----------------------- | -------------------------------------------------------------- |
| UTF-8 text file | The filename and decoded text |
| Directory | A non-recursive listing of its immediate files and directories |
| PNG, JPEG, GIF, or WebP | Image media |
```text
Summarize the attached README.md and list the required setup steps.
```
Desktop file-picker selections can total up to 20 MiB. Other interfaces may
apply lower client-side limits.
SVG files are treated as text, not image media. PDF, AVIF, BMP, audio, video,
and other binary prompt attachments are not currently included in the model
request. A client accepting a file does not mean its contents are visible to
the model.
<Callout type="warning">
Choose a model with image input before attaching an image. OpenCode can pass supported images to the provider, but the
provider and model still enforce their own formats, dimensions, file counts, and sizes. A text-only model may reject the
request.
Use a model that supports image input before attaching an image. OpenCode passes supported image media to the selected
provider, but the provider and model still enforce their own formats, dimensions, file counts, and size limits. A
text-only model may reject the request.
</Callout>
## Syntax
## Add attachments
V2 prompt and command inputs describe an attachment with a `uri` and optional
`name` and `description`:
Desktop and web clients provide **Attach file**, paste, and drag-and-drop controls for supported text and image files. The
desktop file picker limits one selection to 20 MiB in total; the server also applies the per-attachment limit below.
```json
{
"uri": "file:///home/me/project/src/server.ts",
"name": "server.ts",
"description": "HTTP server entrypoint"
}
```
Attachment controls and client-side limits depend on the interface. For programmatic submission, see the generated
[API reference](/api).
Use an absolute `file:` URL for a file or directory that is available to the
server. For text files, positive `start` and `end` parameters select one-based
lines.
V2 prompt and command inputs represent each attachment with a `uri` and
optional `name` and `description`. Use an absolute `file:` URL for a file or
directory available to the server, or an inline `data:` URL. For a text `file:`
URL, optional positive `start` and `end` query parameters select one-based
lines:
```text
file:///home/me/project/src/server.ts?start=20&end=60
```
Use a `data:` URL to send content inline:
HTTP and HTTPS attachment URLs are not supported. OpenCode materializes each
attachment before admitting the prompt and rejects invalid URLs, unreadable
paths, non-files other than directories, and attachments over 20 MiB decoded.
The server infers the media type from the bytes. A supplied filename or data
URL media type does not make an unsupported binary format model-visible.
```json
{
"uri": "data:text/plain;base64,SGVsbG8sIE9wZW5Db2RlIQ==",
"name": "greeting.txt"
}
```
HTTP and HTTPS attachment URLs are not supported. See the generated
[API reference](/api) for programmatic prompt submission.
## Formats
Current V2 sessions make these attachment types visible to the model:
| Input | Model receives | Example |
| ----------------------- | -------------------------------------------------------------- | ------------------ |
| UTF-8 text file | Filename and decoded text | `README.md` |
| Directory | Non-recursive listing of immediate files and directories | `file:///home/me/` |
| PNG, JPEG, GIF, or WebP | Image media | `diagram.png` |
SVG is treated as text. PDF, AVIF, BMP, audio, video, and other binary prompt
attachments are not included in the model request. Convert an unsupported
binary to text or a supported image first; for example, export a PDF page as
`page-1.png` before attaching it.
OpenCode reads each attachment before admitting the prompt. It rejects invalid
URLs, unreadable paths, paths other than files or directories, and decoded
attachments over 20 MiB. Media type is detected from the bytes, so changing a
filename or `data:` URL media type does not make an unsupported binary visible.
## Images
## Configure image processing
Configure image normalization in `opencode.json` or `opencode.jsonc`:
@@ -93,70 +67,41 @@ Configure image normalization in `opencode.json` or `opencode.jsonc`:
All fields are optional:
| Field | Default | Behavior |
| ------------------ | --------- | ------------------------------------------------------------------------- |
| `auto_resize` | `true` | Resize an image over a configured limit; when `false`, reject the image. |
| `max_width` | `2000` | Maximum width in pixels; must be a positive integer. |
| `max_height` | `2000` | Maximum height in pixels; must be a positive integer. |
| `max_base64_bytes` | `5242880` | Maximum bytes in the Base64-encoded image; must be a positive integer. |
| Field | Default | Behavior |
| ------------------ | --------: | ----------------------------------------------------------------------------------- |
| `auto_resize` | `true` | Resize an image that exceeds any configured limit. If `false`, reject it. |
| `max_width` | `2000` | Maximum width in pixels. Must be a positive integer. |
| `max_height` | `2000` | Maximum height in pixels. Must be a positive integer. |
| `max_base64_bytes` | `5242880` | Maximum byte length of the Base64-encoded image string. Must be a positive integer. |
For example, this rejects rather than resizes an image wider than 1200 pixels:
<Callout type="note">
These settings apply to supported image media attached directly to prompts and images produced by the built-in `read`
tool. If the image resizer is unavailable, OpenCode passes the original image through unchanged.
</Callout>
```jsonc title="opencode.jsonc"
{
"media": {
"image": {
"auto_resize": false,
"max_width": 1200,
},
},
}
```
The `read` tool recognizes PNG, JPEG, GIF, and WebP by their contents and will
ingest at most 20 MiB of source image bytes. It decodes the image and compares
its width, height, and encoded Base64 length with all three configured limits.
These settings apply both to supported images attached to prompts and to images
returned by the built-in `read` tool.
When `auto_resize` is `true`, OpenCode preserves the aspect ratio, scales the
image down to the dimension limits, and tries progressively smaller PNG and
JPEG encodings until the Base64 limit is met. The resulting media type can
therefore change to PNG or JPEG. If no encoding fits, the tool call fails.
## Processing
When `auto_resize` is `false`, exceeding any limit fails the tool call without
modifying the image. An image that cannot be decoded also fails. If the image resizer cannot be loaded, OpenCode uses the
original image instead, so these settings are processing limits rather than an upload or security boundary.
The `read` tool recognizes PNG, JPEG, GIF, and WebP by their contents and reads
up to 20 MiB of source image data. It checks width, height, and Base64 length
against the configured image limits.
## Limits and provider behavior
With `auto_resize: true`, OpenCode preserves the aspect ratio and scales down
to the dimension limits. It then tries progressively smaller PNG and JPEG
encodings until the Base64 limit is met, so the output media type can change.
```text
Input: 4000 × 2000 WebP
Limits: 2000 × 2000
Output: 2000 × 1000 PNG or JPEG
```
If no encoding fits, processing fails. With `auto_resize: false`, an image that
exceeds any limit fails without modification; an image that cannot be decoded
also fails.
```text
Input: 2400 × 1600 JPEG
Limit: max_width = 2000, auto_resize = false
Result: Image processing fails
```
If the image resizer is unavailable, OpenCode passes the original image through
unchanged. Image settings are therefore processing limits, not an upload or
security boundary.
## Limits
| Limit | Value or behavior | Example |
| ----------------------------- | ------------------------------------------------------------- | -------------------------------------------- |
| Direct attachment | 20 MiB decoded per item; clients may impose lower limits | Two 12 MiB files pass the per-item limit |
| Desktop picker selection | 20 MiB total | Two 12 MiB files exceed the selection limit |
| `max_base64_bytes` | Encoded Base64 only, excluding the complete `data:` URL | `SGVsbG8=` counts as 8 bytes |
| Provider image limits | Apply after OpenCode processing | A provider may reject an accepted image |
| Text attachment model support | Does not require a multimodal model | `notes.txt` is inserted as prompt text |
| `read` text limits | Uses separate paging and truncation limits | Read a large log in pages |
A client accepting a file does not guarantee that its contents reach the
model. The attachment must use a model-visible format and satisfy both OpenCode
and provider limits.
- Direct prompt attachments are limited to 20 MiB decoded per item by the V2
server. Client-specific limits can be lower.
- `max_base64_bytes` counts the encoded Base64 characters in bytes, not the
decoded file size and not the complete `data:` URL.
- Text attachments are inserted into the prompt as text and do not require a
multimodal model. Large text read through the `read` tool has separate
paging and truncation limits.
- Image attachments use provider-native image input. Provider errors can still
occur when OpenCode's limits pass but the selected model's limits do not.
- PDFs and other unsupported binary prompt attachments should be converted to
text or supported images before attaching them.
@@ -7,6 +7,10 @@ API. Use it when your application connects to an OpenCode server over the
network. Its native types and methods are generated from the same contract as the
[API reference](/api). Plugin RPC types come from imported RPC definitions.
<Callout type="warning">
The V2 API and client are beta. Method names, inputs, and outputs may change before the stable release.
</Callout>
## Install
```sh
@@ -510,7 +510,7 @@ Expose the CLI plugin through `./tui`; add OpenTUI peers when the plugin renders
"./tui": "./src/tui.tsx"
},
"dependencies": {
"@opencode/plugin": "latest"
"@opencode/plugin": "beta"
},
"peerDependencies": {
"@opentui/core": ">=0.5.8",
@@ -1099,7 +1099,7 @@ effect: (ctx) =>
const session = ctx.session
yield* session.hook("context", (event) =>
Effect.sync(() => {
event.system.push({ type: "text", text: "Keep the review focused on correctness." })
event.system.push({ text: "Keep the review focused on correctness." })
delete event.tools.write
}),
)
@@ -1326,7 +1326,7 @@ entrypoint and declare both runtime dependencies.
".": "./src/index.ts"
},
"dependencies": {
"@opencode/plugin": "latest",
"@opencode/plugin": "beta",
"effect": "4.0.0-rc.111"
}
}

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