mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 06:53:27 -04:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 94ec27d03f | |||
| 33567c5792 | |||
| daf3f9ed08 | |||
| d5bf8799c0 | |||
| f6f64d7ece | |||
| 6baad7fc3e | |||
| 0762d63b6a | |||
| 4df0591025 | |||
| 30cb420900 |
@@ -193,7 +193,7 @@ If you find yourself copying a 3-to-5-line snippet between two protocols, lift i
|
||||
|
||||
`LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only.
|
||||
|
||||
Native chronological system messages are route/model-specific. Anthropic Messages lowers them natively for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
|
||||
Native chronological system messages are route/model-specific. Open Responses lowers them to standard `developer` messages, while Anthropic Messages lowers them to native system messages for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
|
||||
|
||||
```text
|
||||
<system-update>
|
||||
|
||||
@@ -57,17 +57,42 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
const OpenResponsesReasoningSummaryText = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenResponsesReasoningItem = Schema.Struct({
|
||||
type: Schema.tag("reasoning"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
summary: Schema.Array(OpenResponsesReasoningSummaryText),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
})
|
||||
const OpenResponsesReasoningContentText = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["reasoning_text", "output_text"]),
|
||||
text: Schema.String,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const OpenResponsesReasoningItem = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("reasoning"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
summary: Schema.Array(OpenResponsesReasoningSummaryText),
|
||||
content: optionalNull(Schema.Array(OpenResponsesReasoningContentText)),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
status: Schema.optional(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type OpenResponsesReasoningItem = Schema.Schema.Type<typeof OpenResponsesReasoningItem>
|
||||
type MutableReasoningItem = {
|
||||
type: "reasoning"
|
||||
id?: string
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
content?: ReadonlyArray<{ type: "reasoning_text" | "output_text"; text: string }> | null
|
||||
encrypted_content?: string | null
|
||||
status?: string
|
||||
}
|
||||
|
||||
const OpenResponsesItemReference = Schema.Struct({
|
||||
type: Schema.tag("item_reference"),
|
||||
@@ -90,6 +115,7 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("developer"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
@@ -119,16 +145,6 @@ type LoweredInputItem =
|
||||
readonly phase?: MessagePhase | null
|
||||
}
|
||||
|
||||
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
type OpenResponsesReasoningInput = {
|
||||
type: "reasoning"
|
||||
id: string
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
type OpenResponsesReasoningReplay = Omit<OpenResponsesReasoningInput, "id">
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
name: Schema.String,
|
||||
@@ -140,6 +156,11 @@ export const Tool = Schema.Struct({
|
||||
export const ToolChoice = Schema.Union([
|
||||
Schema.Literals(["auto", "none", "required"]),
|
||||
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("allowed_tools"),
|
||||
mode: Schema.Literals(["auto", "none", "required"]),
|
||||
tools: Schema.Array(Schema.Struct({ type: Schema.tag("function"), name: Schema.String })),
|
||||
}),
|
||||
])
|
||||
|
||||
// Fields shared between the HTTP body and the WebSocket `response.create`
|
||||
@@ -153,6 +174,7 @@ export const coreFields = {
|
||||
tools: optionalArray(Tool),
|
||||
tool_choice: Schema.optional(ToolChoice),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
truncation: Schema.optional(OpenResponsesOptions.TruncationSchema),
|
||||
service_tier: Schema.optional(OpenResponsesOptions.ServiceTierSchema),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
include: optionalArray(OpenResponsesOptions.ResponseIncludableSchema),
|
||||
@@ -168,6 +190,8 @@ export const coreFields = {
|
||||
}),
|
||||
),
|
||||
max_output_tokens: Schema.optional(Schema.Number),
|
||||
max_tool_calls: Schema.optional(Schema.Int),
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
}
|
||||
@@ -254,6 +278,7 @@ export const Event = Schema.StructWithRest(
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
content_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(StreamItem),
|
||||
response: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
@@ -263,6 +288,7 @@ export const Event = Schema.StructWithRest(
|
||||
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
|
||||
usage: optionalNull(OpenResponsesUsage),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
output: optionalArray(StreamItem),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
@@ -303,17 +329,14 @@ export interface ParserState {
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
readonly reasoningOutputItems: Readonly<Record<string, OpenResponsesReasoningItem>>
|
||||
readonly completedReasoningItems: ReadonlySet<string>
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly encryptedContent: string | null | undefined
|
||||
// Keyed by the wire protocol's numeric `summary_index`. JS object keys coerce to
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
// and matches the wire field.
|
||||
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
|
||||
readonly summary: Readonly<Record<number, string>>
|
||||
readonly content: Readonly<Record<number, string>>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -351,19 +374,28 @@ const lowerToolCall = (part: ToolCallPart): OpenResponsesInputItem => ({
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
})
|
||||
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const lowerReasoning = (
|
||||
part: ReasoningPart,
|
||||
providerMetadataKey: string,
|
||||
): { readonly id: string; readonly item: OpenResponsesReasoningItem; readonly native: boolean } | undefined => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string" || metadata.itemId.length === 0)
|
||||
return undefined
|
||||
if (!ProviderShared.isRecord(metadata)) return undefined
|
||||
if (typeof metadata.itemId !== "string" || metadata.itemId.length === 0) return undefined
|
||||
if (Schema.is(OpenResponsesReasoningItem)(metadata.reasoningItem))
|
||||
return { id: metadata.itemId, item: metadata.reasoningItem, native: true }
|
||||
const encryptedContent =
|
||||
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
|
||||
? metadata.reasoningEncryptedContent
|
||||
: undefined
|
||||
return {
|
||||
type: "reasoning",
|
||||
id: metadata.itemId,
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
native: false,
|
||||
item: {
|
||||
type: "reasoning",
|
||||
id: metadata.itemId,
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,19 +466,16 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
const system: LoweredInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: LoweredInputItem[] = [...system]
|
||||
const nativeReasoningItems = new Set<OpenResponsesReasoningItem>()
|
||||
const store = OpenResponsesOptions.resolve(request).store
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "system") {
|
||||
const part = yield* ProviderShared.wrappedSystemUpdate(extension.name, message)
|
||||
const previous = input.at(-1)
|
||||
if (previous && "role" in previous && previous.role === "user")
|
||||
input[input.length - 1] = {
|
||||
role: "user",
|
||||
content: [...previous.content, { type: "input_text", text: part.text }],
|
||||
}
|
||||
else input.push({ role: "user", content: [{ type: "input_text", text: part.text }] })
|
||||
input.push({
|
||||
role: "developer",
|
||||
content: ProviderShared.joinText(yield* ProviderShared.systemUpdateText(extension.name, message)),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -460,7 +489,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const content: TextPart[] = []
|
||||
const reasoningItems: Record<string, OpenResponsesReasoningReplay> = {}
|
||||
const reasoningItems: Record<string, MutableReasoningItem> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
@@ -494,25 +523,30 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part, providerMetadataKey)
|
||||
if (!reasoning) continue
|
||||
const id = reasoning.id
|
||||
if (store !== false) {
|
||||
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||
reasoningReferences.add(reasoning.id)
|
||||
if (id && !reasoningReferences.has(id)) input.push({ type: "item_reference", id })
|
||||
if (id) reasoningReferences.add(id)
|
||||
continue
|
||||
}
|
||||
const existing = reasoningItems[reasoning.id]
|
||||
if (reasoning.native) {
|
||||
if (!id || !reasoningItems[id]) input.push(reasoning.item)
|
||||
if (id) reasoningItems[id] = { ...reasoning.item, summary: [...reasoning.item.summary] }
|
||||
nativeReasoningItems.add(reasoning.item)
|
||||
continue
|
||||
}
|
||||
if (!id) continue
|
||||
const existing = reasoningItems[id]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
if (typeof reasoning.encrypted_content === "string")
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
existing.summary.push(...reasoning.item.summary)
|
||||
if (typeof reasoning.item.encrypted_content === "string")
|
||||
existing.encrypted_content = reasoning.item.encrypted_content
|
||||
continue
|
||||
}
|
||||
const replay = {
|
||||
type: reasoning.type,
|
||||
summary: reasoning.summary,
|
||||
encrypted_content: reasoning.encrypted_content,
|
||||
}
|
||||
reasoningItems[reasoning.id] = replay
|
||||
input.push(replay)
|
||||
const { id: _id, ...replay } = reasoning.item
|
||||
const replayItem = { ...replay, summary: [...replay.summary] }
|
||||
reasoningItems[id] = replayItem
|
||||
input.push(replayItem)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -558,12 +592,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
}
|
||||
|
||||
// With store:false, Responses APIs only accept previous reasoning items when the
|
||||
// complete item has encrypted state. Summary blocks for one item may carry
|
||||
// that state only on the last block, so filter after they have been joined.
|
||||
return store === false
|
||||
? input.filter(
|
||||
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
|
||||
(item) =>
|
||||
!("type" in item) ||
|
||||
item.type !== "reasoning" ||
|
||||
nativeReasoningItems.has(item) ||
|
||||
typeof item.encrypted_content === "string",
|
||||
)
|
||||
: input
|
||||
})
|
||||
@@ -580,6 +615,19 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
: {}),
|
||||
...(options.textVerbosity ? { text: { verbosity: options.textVerbosity } } : {}),
|
||||
...(options.serviceTier ? { service_tier: options.serviceTier } : {}),
|
||||
...(options.maxToolCalls !== undefined ? { max_tool_calls: options.maxToolCalls } : {}),
|
||||
...(options.parallelToolCalls !== undefined ? { parallel_tool_calls: options.parallelToolCalls } : {}),
|
||||
...(options.truncation ? { truncation: options.truncation } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const allowedToolChoice = (request: LLMRequest) => {
|
||||
const allowed = OpenResponsesOptions.resolve(request).allowedTools
|
||||
if (!allowed) return undefined
|
||||
return {
|
||||
type: "allowed_tools" as const,
|
||||
mode: allowed.mode,
|
||||
tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,7 +650,9 @@ export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWith
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined,
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(extension.name, request.toolChoice) : undefined),
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
@@ -661,6 +711,13 @@ export const providerMetadata = (state: ParserState, metadata: Record<string, un
|
||||
const isReasoningItem = (item: StreamItem): item is StreamItem & { type: "reasoning"; id: string } =>
|
||||
item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0
|
||||
|
||||
type ReasoningOutputItem = {
|
||||
readonly [key: string]: unknown
|
||||
readonly type: "reasoning"
|
||||
readonly id: string
|
||||
readonly encrypted_content?: string | null
|
||||
}
|
||||
|
||||
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
@@ -689,15 +746,42 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
const emptyReasoningItem = (): ReasoningStreamItem => ({
|
||||
encryptedContent: undefined,
|
||||
summary: {},
|
||||
content: {},
|
||||
})
|
||||
|
||||
const joinedReasoning = (parts: Readonly<Record<number, string>>) =>
|
||||
Object.entries(parts)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map((entry) => entry[1])
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n")
|
||||
|
||||
export const onReasoningDelta = (
|
||||
state: ParserState,
|
||||
event: Event,
|
||||
itemID: string,
|
||||
source: "summary" | "content",
|
||||
): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[itemID] ?? emptyReasoningItem()
|
||||
const index = source === "summary" ? (event.summary_index ?? 0) : (event.content_index ?? 0)
|
||||
const parts = source === "summary" ? item.summary : item.content
|
||||
const previous = parts[index] ?? ""
|
||||
const events: LLMEvent[] = []
|
||||
const id =
|
||||
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
|
||||
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, itemID),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[itemID]: {
|
||||
...item,
|
||||
[source]: { ...parts, [index]: `${previous}${event.delta}` },
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
@@ -705,8 +789,85 @@ export const onReasoningDelta = (state: ParserState, event: Event, itemID: strin
|
||||
|
||||
export const onReasoningDone = (state: ParserState, _event: Event): StepResult => [state, NO_EVENTS]
|
||||
|
||||
const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }) =>
|
||||
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
const completedReasoningItem = (
|
||||
item: ReasoningOutputItem,
|
||||
streamed: ReasoningStreamItem,
|
||||
): OpenResponsesReasoningItem => {
|
||||
if (Schema.is(OpenResponsesReasoningItem)(item)) return item
|
||||
const summary = Object.entries(streamed.summary)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map((entry) => ({ type: "summary_text" as const, text: entry[1] }))
|
||||
const content = Object.entries(streamed.content)
|
||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||
.map((entry) => ({ type: "reasoning_text" as const, text: entry[1] }))
|
||||
return {
|
||||
type: "reasoning",
|
||||
id: item.id,
|
||||
summary,
|
||||
...(content.length > 0 ? { content } : {}),
|
||||
...(item.encrypted_content !== undefined ? { encrypted_content: item.encrypted_content } : {}),
|
||||
...(typeof item.status === "string" ? { status: item.status } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const completeReasoning = (state: ParserState, item: ReasoningOutputItem): StepResult => {
|
||||
if (state.completedReasoningItems.has(item.id)) return [state, NO_EVENTS]
|
||||
const streamed = state.reasoningItems[item.id] ?? emptyReasoningItem()
|
||||
const reasoningItem = completedReasoningItem(item, streamed)
|
||||
const finalSummary = reasoningItem.summary.map((part) => part.text).join("\n\n")
|
||||
const summary = finalSummary || joinedReasoning(streamed.summary)
|
||||
const content = reasoningItem.content
|
||||
? reasoningItem.content.map((part) => part.text).join("\n\n")
|
||||
: joinedReasoning(streamed.content)
|
||||
const text = summary || content
|
||||
const { id: _id, ...replayItem } = reasoningItem
|
||||
const reasoningReplay =
|
||||
replayItem.content && replayItem.content.length > 0
|
||||
? replayItem
|
||||
: Object.fromEntries(Object.entries(replayItem).filter((entry) => entry[0] !== "content"))
|
||||
const metadata = providerMetadata(state, {
|
||||
itemId: item.id,
|
||||
reasoningEncryptedContent: reasoningItem.encrypted_content ?? null,
|
||||
reasoningItem: reasoningReplay,
|
||||
})
|
||||
const events: LLMEvent[] = []
|
||||
const started = Lifecycle.reasoningStart(state.lifecycle, events, item.id)
|
||||
const lifecycle = Lifecycle.reasoningEnd(
|
||||
text.length > 0 ? Lifecycle.reasoningDelta(started, events, item.id, text) : started,
|
||||
events,
|
||||
item.id,
|
||||
metadata,
|
||||
)
|
||||
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
reasoningItems,
|
||||
completedReasoningItems: new Set([...state.completedReasoningItems, item.id]),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const stageReasoning = (state: ParserState, item: ReasoningOutputItem): StepResult => {
|
||||
const streamed = state.reasoningItems[item.id] ?? emptyReasoningItem()
|
||||
const reasoningItem = completedReasoningItem(item, streamed)
|
||||
const summary = reasoningItem.summary.map((part) => part.text).join("\n\n") || joinedReasoning(streamed.summary)
|
||||
const content = reasoningItem.content
|
||||
? reasoningItem.content.map((part) => part.text).join("\n\n")
|
||||
: joinedReasoning(streamed.content)
|
||||
if (summary || content || typeof reasoningItem.encrypted_content === "string") return completeReasoning(state, item)
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, item.id),
|
||||
reasoningOutputItems: { ...state.reasoningOutputItems, [item.id]: reasoningItem },
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
// Responses APIs stream reasoning items in a stable order:
|
||||
// `output_item.added` (reasoning) →
|
||||
@@ -714,12 +875,10 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// `reasoning_summary_text.delta` →
|
||||
// `reasoning_summary_part.done` (index=0) →
|
||||
// (repeat for index>0) →
|
||||
// `output_item.done` (reasoning).
|
||||
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
|
||||
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
|
||||
// short-circuits when the entry already exists, and higher-index handlers
|
||||
// fold against the same entry. Behaviour for out-of-order events is
|
||||
// best-effort, not guaranteed.
|
||||
// `output_item.done` (reasoning) →
|
||||
// `response.completed`.
|
||||
// Buffer deltas until `output_item.done` can choose summary over raw content.
|
||||
// Sparse item completions remain open for recovery from terminal output.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id)
|
||||
@@ -739,10 +898,15 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(state, item)),
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
providerMetadata(state, { itemId: item.id }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
||||
[item.id]: { ...emptyReasoningItem(), encryptedContent: item.encrypted_content },
|
||||
},
|
||||
},
|
||||
events,
|
||||
@@ -772,63 +936,20 @@ const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
|
||||
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
|
||||
if (event.summary_index === 0) {
|
||||
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:0`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const item = state.reasoningItems[event.item_id] ?? emptyReasoningItem()
|
||||
const events: LLMEvent[] = []
|
||||
const closed = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${entry[0]}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
closed,
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
event.item_id,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
|
||||
),
|
||||
),
|
||||
[event.summary_index]: "active",
|
||||
},
|
||||
},
|
||||
[event.item_id]: item,
|
||||
},
|
||||
},
|
||||
events,
|
||||
@@ -837,34 +958,7 @@ const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResu
|
||||
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResult => {
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle:
|
||||
state.store !== false
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
providerMetadata(state, { itemId: event.item_id }),
|
||||
)
|
||||
: state.lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...item.summaryParts,
|
||||
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
return [state, NO_EVENTS]
|
||||
}
|
||||
|
||||
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
|
||||
@@ -940,29 +1034,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
}
|
||||
|
||||
if (isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
const metadata = reasoningMetadata(state, item)
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
if (reasoningItem) {
|
||||
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
||||
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata: metadata }))
|
||||
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }))
|
||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, metadata) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
return stageReasoning(state, item)
|
||||
}
|
||||
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
@@ -970,7 +1042,31 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
|
||||
const onResponseFinish = (state: ParserState, event: Event): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
const terminal = (event.response?.output ?? [])
|
||||
.filter(isReasoningItem)
|
||||
.filter((item) => !state.completedReasoningItems.has(item.id))
|
||||
const terminalIDs = new Set(terminal.map((item) => item.id))
|
||||
const output = Object.values(state.reasoningOutputItems).filter(
|
||||
(item): item is OpenResponsesReasoningItem & { readonly id: string } =>
|
||||
typeof item.id === "string" &&
|
||||
item.id.length > 0 &&
|
||||
!terminalIDs.has(item.id) &&
|
||||
!state.completedReasoningItems.has(item.id),
|
||||
)
|
||||
const completedIDs = new Set([...terminalIDs, ...output.map((item) => item.id)])
|
||||
const buffered = Object.entries(state.reasoningItems)
|
||||
.filter((entry) => !completedIDs.has(entry[0]))
|
||||
.map(([id, item]) => ({ type: "reasoning" as const, id, encrypted_content: item.encryptedContent }))
|
||||
const reasoning = [...terminal, ...output, ...buffered]
|
||||
const completed = reasoning.reduce(
|
||||
(result, item) => {
|
||||
const next = completeReasoning(result[0], item)
|
||||
result[1].push(...next[1])
|
||||
return [next[0], result[1]] satisfies [ParserState, LLMEvent[]]
|
||||
},
|
||||
[state, events] satisfies [ParserState, LLMEvent[]],
|
||||
)
|
||||
const lifecycle = Lifecycle.finish(completed[0].lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event, state.hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
@@ -984,7 +1080,7 @@ const onResponseFinish = (state: ParserState, event: Event): StepResult => {
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...state, lifecycle }, events]
|
||||
return [{ ...completed[0], lifecycle }, events]
|
||||
}
|
||||
|
||||
// Build a single human-readable message from whatever the provider supplied.
|
||||
@@ -1029,7 +1125,14 @@ export const step = (state: ParserState, event: Event) => {
|
||||
}
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
return Effect.succeed(
|
||||
onReasoningDelta(
|
||||
state,
|
||||
event,
|
||||
event.item_id,
|
||||
event.type === "response.reasoning_summary_text.delta" ? "summary" : "content",
|
||||
),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
@@ -1083,7 +1186,8 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
messagePhase: (value) => messagePhase(value, extension),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
reasoningOutputItems: {},
|
||||
completedReasoningItems: new Set<string>(),
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
||||
|
||||
@@ -67,12 +67,10 @@ const comparable = (value: unknown) => {
|
||||
name: value.name,
|
||||
arguments: json(value.arguments),
|
||||
}
|
||||
if (value.type === "reasoning")
|
||||
return {
|
||||
type: value.type,
|
||||
summary: value.summary,
|
||||
encrypted_content: value.encrypted_content,
|
||||
}
|
||||
if (value.type === "reasoning") {
|
||||
const { id: _id, ...reasoning } = value
|
||||
return reasoning
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -141,6 +139,8 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
)
|
||||
const observation = yield* input.base.observe(create, frame)
|
||||
if (event.type === "response.output_item.done" && event.item) output.push(event.item)
|
||||
if ((event.type === "response.completed" || event.type === "response.incomplete") && event.response?.output)
|
||||
output = [...event.response.output]
|
||||
if (observation.type === "provider-failure") {
|
||||
const rejection = code(event)
|
||||
if (rejection === "previous_response_not_found") return rejected(input, observation, "retry-full")
|
||||
|
||||
@@ -121,7 +121,8 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
: yield* Effect.forEach(request.tools, (tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
|
||||
tool_choice:
|
||||
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
} satisfies OpenAIResponsesBody
|
||||
})
|
||||
|
||||
@@ -215,7 +216,14 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
? Effect.succeed(
|
||||
OpenResponses.onReasoningDelta(
|
||||
state,
|
||||
event,
|
||||
event.item_id,
|
||||
event.type === "response.reasoning_summary.delta" ? "summary" : "content",
|
||||
),
|
||||
)
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return event.item_id
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import { Option, Schema } from "effect"
|
||||
import { TextVerbosity, type LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export const ResponseIncludables = [
|
||||
@@ -11,52 +11,62 @@ export const ResponseIncludables = [
|
||||
"reasoning.encrypted_content",
|
||||
"message.output_text.logprobs",
|
||||
] as const
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number]
|
||||
export type ResponseIncludable = (typeof ResponseIncludables)[number] | (string & {})
|
||||
|
||||
export const ServiceTiers = ["auto", "default", "flex", "priority"] as const
|
||||
export type ServiceTier = (typeof ServiceTiers)[number]
|
||||
|
||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||
const INCLUDABLES = new Set<string>(ResponseIncludables)
|
||||
const SERVICE_TIERS = new Set<string>(ServiceTiers)
|
||||
|
||||
const isTextVerbosity = (value: unknown): value is Schema.Schema.Type<typeof TextVerbosity> =>
|
||||
typeof value === "string" && TEXT_VERBOSITY.has(value)
|
||||
|
||||
const isServiceTier = (value: unknown): value is ServiceTier => typeof value === "string" && SERVICE_TIERS.has(value)
|
||||
export const Truncations = ["auto", "disabled"] as const
|
||||
export type Truncation = (typeof Truncations)[number]
|
||||
|
||||
export const ReasoningEffort = Schema.String
|
||||
export const TextVerbositySchema = TextVerbosity
|
||||
export const ResponseIncludableSchema = Schema.Literals(ResponseIncludables)
|
||||
export const ResponseIncludableSchema = Schema.declare<ResponseIncludable>(
|
||||
(value): value is ResponseIncludable => typeof value === "string",
|
||||
{ title: "ResponseIncludable" },
|
||||
)
|
||||
export const ServiceTierSchema = Schema.Literals(ServiceTiers)
|
||||
export const TruncationSchema = Schema.Literals(Truncations)
|
||||
|
||||
export interface Resolved {
|
||||
readonly instructions?: string
|
||||
readonly store?: boolean
|
||||
readonly reasoningEffort?: string
|
||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||
readonly textVerbosity?: Schema.Schema.Type<typeof TextVerbosity>
|
||||
readonly serviceTier?: ServiceTier
|
||||
export const AllowedTools = Schema.Struct({
|
||||
toolNames: Schema.Array(Schema.String),
|
||||
mode: Schema.optional(Schema.Literals(["auto", "none", "required"])),
|
||||
})
|
||||
export type AllowedTools = typeof AllowedTools.Type
|
||||
|
||||
export const Options = Schema.Struct({
|
||||
instructions: Schema.optional(Schema.String),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
reasoningEffort: Schema.optional(ReasoningEffort),
|
||||
reasoningSummary: Schema.optional(Schema.Literals(["auto", "concise", "detailed"])),
|
||||
include: Schema.optional(Schema.Array(ResponseIncludableSchema)),
|
||||
textVerbosity: Schema.optional(TextVerbositySchema),
|
||||
serviceTier: Schema.optional(ServiceTierSchema),
|
||||
truncation: Schema.optional(TruncationSchema),
|
||||
allowedTools: Schema.optional(AllowedTools),
|
||||
maxToolCalls: Schema.optional(Schema.Int),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
export type Options = typeof Options.Type
|
||||
|
||||
export type Resolved = Omit<Options, "allowedTools"> & {
|
||||
readonly allowedTools?: AllowedTools & { readonly mode: NonNullable<AllowedTools["mode"]> }
|
||||
}
|
||||
|
||||
const decodeOptions = Schema.decodeUnknownOption(Options)
|
||||
|
||||
export const resolve = (request: LLMRequest): Resolved => {
|
||||
const input = request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]
|
||||
const include = Array.isArray(input?.include)
|
||||
? input.include.filter((entry): entry is ResponseIncludable => INCLUDABLES.has(entry))
|
||||
: []
|
||||
const reasoningSummary = input?.reasoningSummary
|
||||
const input = Option.getOrUndefined(
|
||||
decodeOptions(request.providerOptions?.[request.model.route.providerMetadataKey ?? "openresponses"]),
|
||||
)
|
||||
if (!input) return {}
|
||||
return {
|
||||
instructions: typeof input?.instructions === "string" ? input.instructions : undefined,
|
||||
store: typeof input?.store === "boolean" ? input.store : undefined,
|
||||
reasoningEffort: typeof input?.reasoningEffort === "string" ? input.reasoningEffort : undefined,
|
||||
reasoningSummary:
|
||||
reasoningSummary === "auto" || reasoningSummary === "concise" || reasoningSummary === "detailed"
|
||||
? reasoningSummary
|
||||
...input,
|
||||
include: input.include?.length ? input.include : undefined,
|
||||
allowedTools:
|
||||
input.allowedTools && input.allowedTools.toolNames.length > 0
|
||||
? { ...input.allowedTools, mode: input.allowedTools.mode ?? "auto" }
|
||||
: undefined,
|
||||
include: include.length > 0 ? include : undefined,
|
||||
textVerbosity: isTextVerbosity(input?.textVerbosity) ? input.textVerbosity : undefined,
|
||||
serviceTier: isServiceTier(input?.serviceTier) ? input.serviceTier : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
import type { ResponseIncludable, ServiceTier } from "../protocols/utils/open-responses-options.js"
|
||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema/index.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
import type { ProviderOptions } from "../schema/index.js"
|
||||
|
||||
export interface OpenResponsesOptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
readonly instructions?: string
|
||||
readonly store?: boolean
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: "auto" | "concise" | "detailed"
|
||||
readonly include?: ReadonlyArray<ResponseIncludable>
|
||||
readonly textVerbosity?: TextVerbosity
|
||||
readonly serviceTier?: ServiceTier
|
||||
}
|
||||
export type OpenResponsesOptionsInput = Options & { readonly [key: string]: unknown }
|
||||
|
||||
export type OpenResponsesProviderOptionsInput = ProviderOptions & {
|
||||
readonly openresponses?: OpenResponsesOptionsInput
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
type ProviderMetadata,
|
||||
type UsageInput,
|
||||
} from "./schema/index.js"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
@@ -33,13 +34,22 @@ export interface LayerOptions {
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ai/TestLLM") {}
|
||||
|
||||
export const complete = (
|
||||
options: { readonly reason: FinishReasonDetails; readonly usage?: UsageInput },
|
||||
options: {
|
||||
readonly reason: FinishReasonDetails
|
||||
readonly usage?: UsageInput
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
},
|
||||
...events: readonly LLMEvent[]
|
||||
) => [
|
||||
LLMEvent.stepStart({ index: 0 }),
|
||||
...events,
|
||||
LLMEvent.stepFinish({ index: 0, reason: options.reason, usage: options.usage }),
|
||||
LLMEvent.finish({ reason: options.reason }),
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: options.reason,
|
||||
usage: options.usage,
|
||||
providerMetadata: options.providerMetadata,
|
||||
}),
|
||||
LLMEvent.finish({ reason: options.reason, providerMetadata: options.providerMetadata }),
|
||||
]
|
||||
|
||||
export const stop = (...events: readonly LLMEvent[]) => complete({ reason: { normalized: "stop" } }, ...events)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
@@ -56,6 +56,28 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates as standard developer messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
provider: "example",
|
||||
}).model("example-model")
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects OpenAI-native tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
@@ -101,13 +123,68 @@ describe("Open Responses-compatible route", () => {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
providerOptions: { openresponses: { reasoningEffort: "low", store: true } },
|
||||
providerOptions: {
|
||||
openresponses: {
|
||||
reasoningEffort: "low",
|
||||
store: true,
|
||||
truncation: "auto",
|
||||
allowedTools: { toolNames: ["lookup"] },
|
||||
maxToolCalls: 2,
|
||||
parallelToolCalls: false,
|
||||
},
|
||||
},
|
||||
}).model("example-model")
|
||||
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Think." }))
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Think.",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
reasoning: { effort: "low" },
|
||||
store: true,
|
||||
truncation: "auto",
|
||||
tool_choice: {
|
||||
type: "allowed_tools",
|
||||
mode: "auto",
|
||||
tools: [{ type: "function", name: "lookup" }],
|
||||
},
|
||||
max_tool_calls: 2,
|
||||
parallel_tool_calls: false,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves native reasoning in the Open Responses namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const reasoningItem = {
|
||||
type: "reasoning" as const,
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text" as const, text: "Short summary." }],
|
||||
content: [{ type: "reasoning_text" as const, text: "Long raw reasoning." }],
|
||||
encrypted_content: "encrypted-state",
|
||||
}
|
||||
const { id: _id, ...replayItem } = reasoningItem
|
||||
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.done", item: reasoningItem },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Short summary.")
|
||||
expect(response.message.content[0]).toMatchObject({
|
||||
providerMetadata: { openresponses: { reasoningItem: replayItem } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -241,27 +241,18 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
|
||||
it.effect("lowers chronological system updates to developer messages in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Before."),
|
||||
Message.system("Treat </system-update> literally."),
|
||||
Message.assistant("After."),
|
||||
],
|
||||
messages: [Message.user("Before."), Message.system("Operator update."), Message.assistant("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "Before." },
|
||||
{ type: "input_text", text: "<system-update>\nTreat </system-update> literally.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Before." }] },
|
||||
{ role: "developer", content: "Operator update." },
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
@@ -498,14 +489,31 @@ describe("OpenAI Responses route", () => {
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
content: [{ type: "reasoning_text", text: "Raw thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
status: "completed",
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_1",
|
||||
output: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
content: [{ type: "reasoning_text", text: "Raw thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
status: "completed",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
const next = continuationDriver({
|
||||
@@ -515,7 +523,9 @@ describe("OpenAI Responses route", () => {
|
||||
{
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "Thought" }],
|
||||
content: [{ type: "reasoning_text", text: "Raw thought" }],
|
||||
encrypted_content: "encrypted",
|
||||
status: "completed",
|
||||
},
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
|
||||
],
|
||||
@@ -1283,11 +1293,20 @@ describe("OpenAI Responses route", () => {
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).model("gpt-5.2"),
|
||||
prompt: "think",
|
||||
promptCacheKey: "session_123",
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "read", description: "Read a file", inputSchema: { type: "object" } }),
|
||||
ToolDefinition.make({ name: "grep", description: "Search files", inputSchema: { type: "object" } }),
|
||||
],
|
||||
toolChoice: "none",
|
||||
providerOptions: {
|
||||
openai: {
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
truncation: "disabled",
|
||||
allowedTools: { toolNames: ["read", "grep"], mode: "required" },
|
||||
maxToolCalls: 4,
|
||||
parallelToolCalls: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -1298,6 +1317,17 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
|
||||
expect(prepared.body.text).toEqual({ verbosity: "low" })
|
||||
expect(prepared.body.truncation).toBe("disabled")
|
||||
expect(prepared.body.tool_choice).toEqual({
|
||||
type: "allowed_tools",
|
||||
mode: "required",
|
||||
tools: [
|
||||
{ type: "function", name: "read" },
|
||||
{ type: "function", name: "grep" },
|
||||
],
|
||||
})
|
||||
expect(prepared.body.max_tool_calls).toBe(4)
|
||||
expect(prepared.body.parallel_tool_calls).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1323,20 +1353,17 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters unknown includable values out of the include array", () =>
|
||||
it.effect("passes forward-compatible includable values through", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
// The user passed one invalid entry alongside a valid one. Keep the
|
||||
// valid one so the request still succeeds rather than failing on a
|
||||
// typo from upstream config.
|
||||
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content", "bogus.thing"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1350,13 +1377,13 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an all-invalid include as no include at all", () =>
|
||||
it.effect("passes an unknown includable value through", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
expect(prepared.body.include).toEqual(["bogus.thing"])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1668,16 +1695,29 @@ describe("OpenAI Responses route", () => {
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "rs_1" },
|
||||
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "reasoning-end", id: "rs_1" },
|
||||
{ type: "reasoning-delta", id: "rs_1", text: "thinking" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: "rs_1",
|
||||
reasoningEncryptedContent: null,
|
||||
reasoningItem: {
|
||||
type: "reasoning",
|
||||
summary: [{ type: "summary_text", text: "thinking" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
])
|
||||
expect(response.events.filter((event) => event.type === "finish")).toHaveLength(1)
|
||||
expect(response.message.content).toEqual([
|
||||
expect(response.message.content).toMatchObject([
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "text", text: "Hello" },
|
||||
])
|
||||
@@ -1710,13 +1750,197 @@ describe("OpenAI Responses route", () => {
|
||||
expect.objectContaining({
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: "rs_1",
|
||||
reasoningEncryptedContent: "encrypted-state",
|
||||
reasoningItem: {
|
||||
type: "reasoning",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [{ type: "summary_text", text: "thinking" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||
it.effect("displays reasoning summaries and replays the native item", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasoningItem = {
|
||||
type: "reasoning" as const,
|
||||
id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text" as const, text: "Checked Codex." },
|
||||
{ type: "summary_text" as const, text: "Checked Pi." },
|
||||
],
|
||||
content: [
|
||||
{ type: "reasoning_text" as const, text: "Raw Codex analysis." },
|
||||
{ type: "reasoning_text" as const, text: "Raw Pi analysis." },
|
||||
],
|
||||
encrypted_content: "encrypted-state",
|
||||
status: "completed",
|
||||
provider_extension: { trace: "native-value" },
|
||||
}
|
||||
const { id: _id, ...replayItem } = reasoningItem
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { store: false } } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_1" } },
|
||||
{
|
||||
type: "response.reasoning_text.delta",
|
||||
item_id: "rs_1",
|
||||
content_index: 0,
|
||||
delta: "Raw Codex analysis.",
|
||||
},
|
||||
{
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
item_id: "rs_1",
|
||||
summary_index: 0,
|
||||
delta: "Checked Codex.",
|
||||
},
|
||||
{ type: "response.output_item.done", item: reasoningItem },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [reasoningItem] } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Checked Codex.\n\nChecked Pi.")
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checked Codex.\n\nChecked Pi.",
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: "rs_1",
|
||||
reasoningEncryptedContent: "encrypted-state",
|
||||
reasoningItem: replayItem,
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [response.message, Message.user("Continue.")],
|
||||
providerOptions: { openai: { store: false } },
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
replayItem,
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses raw reasoning when no summary is available", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasoningItem = {
|
||||
type: "reasoning" as const,
|
||||
id: "rs_raw",
|
||||
summary: [],
|
||||
content: [
|
||||
{ type: "reasoning_text" as const, text: "First raw part." },
|
||||
{ type: "reasoning_text" as const, text: "Second raw part." },
|
||||
],
|
||||
encrypted_content: "encrypted-state",
|
||||
}
|
||||
const { id: _id, ...replayItem } = reasoningItem
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_raw" } },
|
||||
{ type: "response.output_item.done", item: reasoningItem },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("First raw part.\n\nSecond raw part.")
|
||||
expect(response.message.content[0]).toMatchObject({
|
||||
type: "reasoning",
|
||||
text: "First raw part.\n\nSecond raw part.",
|
||||
providerMetadata: { openai: { reasoningItem: replayItem } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves native reasoning for xAI Responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasoningItem = {
|
||||
type: "reasoning" as const,
|
||||
id: "rs_xai",
|
||||
summary: [{ type: "summary_text" as const, text: "xAI summary." }],
|
||||
content: [{ type: "reasoning_text" as const, text: "xAI raw reasoning." }],
|
||||
encrypted_content: "xai-state",
|
||||
}
|
||||
const { id: _id, ...replayItem } = reasoningItem
|
||||
const response = yield* LLMClient.generate(LLM.request({ model: xaiModel, prompt: "Think." })).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.done", item: reasoningItem },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("xAI summary.")
|
||||
expect(response.message.content[0]).toMatchObject({
|
||||
providerMetadata: { xai: { reasoningItem: replayItem } },
|
||||
})
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model: xaiModel, messages: [response.message, Message.user("Continue.")] }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
replayItem,
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses terminal reasoning output over a sparse item completion", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasoningItem = {
|
||||
type: "reasoning" as const,
|
||||
id: "rs_terminal",
|
||||
summary: [{ type: "summary_text" as const, text: "Terminal summary." }],
|
||||
content: [{ type: "reasoning_text" as const, text: "Terminal raw content." }],
|
||||
encrypted_content: "encrypted-state",
|
||||
}
|
||||
const { id: _id, ...replayItem } = reasoningItem
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "reasoning", id: "rs_terminal" } },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_terminal", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [reasoningItem] } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Terminal summary.")
|
||||
expect(response.message.content[0]).toMatchObject({
|
||||
providerMetadata: { openai: { reasoningItem: replayItem } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects reasoning summary parts as one display block", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { store: false } } }),
|
||||
@@ -1744,26 +1968,32 @@ describe("OpenAI Responses route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("FirstSecond")
|
||||
expect(response.reasoning).toBe("First\n\nSecond")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
id: "rs_1",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||
{ type: "reasoning-delta", id: "rs_1", text: "First\n\nSecond" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
id: "rs_1",
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: "rs_1",
|
||||
reasoningEncryptedContent: "encrypted-state",
|
||||
reasoningItem: {
|
||||
type: "reasoning",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First" },
|
||||
{ type: "summary_text", text: "Second" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: { normalized: "stop", raw: undefined } },
|
||||
{ type: "finish", reason: { normalized: "stop", raw: undefined } },
|
||||
@@ -1771,7 +2001,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
it.effect("closes the reasoning item when storage is not disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { providerOptions: { openai: { store: true } } }),
|
||||
@@ -1800,8 +2030,24 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1",
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
itemId: "rs_1",
|
||||
reasoningEncryptedContent: null,
|
||||
reasoningItem: {
|
||||
type: "reasoning",
|
||||
encrypted_content: null,
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First" },
|
||||
{ type: "summary_text", text: "Second" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useData } from "@/context/server"
|
||||
import { pluginLabels } from "@/utils/plugin"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
@@ -102,10 +102,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const globalPlugins = createMemo(() => pluginLabels(globalPluginList.latest ?? []))
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map(pluginLabel))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return pluginLabels(projectPluginList.latest ?? []).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map(pluginLabel).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { pluginLabels } from "@/utils/plugin"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
@@ -45,7 +45,9 @@ export const SettingsExtensionsV2: Component = () => {
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => pluginLabels(pluginList.latest ?? []).map((name) => ({ name })))
|
||||
const plugins = createMemo<PluginRowItem[]>(() =>
|
||||
(pluginList.latest ?? []).map((item) => ({ name: pluginLabel(item) })),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useMcpToggle } from "@/context/mcp"
|
||||
import { useWorkspaceLocation } from "@/context/location"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { pluginLabels } from "@/utils/plugin"
|
||||
import { pluginLabel } from "@/utils/plugin"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -39,7 +39,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo(() => pluginLabels(pluginList.latest ?? []))
|
||||
const plugins = createMemo(() => (pluginList.latest ?? []).map(pluginLabel))
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import { pluginLabels } from "./plugin"
|
||||
|
||||
describe("pluginLabels", () => {
|
||||
test("omits built-in plugins", () => {
|
||||
const plugins: PluginInfo[] = [
|
||||
{ id: "opencode.internal", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{ id: "package-plugin", source: { type: "package", package: "example" }, status: "active", tui: false },
|
||||
{ id: "local-plugin", source: { type: "local", path: "/tmp/plugin.ts" }, status: "active", tui: false },
|
||||
{ id: "sdk-plugin", source: { type: "sdk" }, status: "active", tui: false },
|
||||
]
|
||||
|
||||
expect(pluginLabels(plugins)).toEqual(["package-plugin", "local-plugin", "sdk-plugin"])
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,3 @@ export function pluginLabel(plugin: PluginInfo) {
|
||||
if (plugin.source.type === "local") return plugin.source.path
|
||||
return plugin.source.type
|
||||
}
|
||||
|
||||
export function pluginLabels(plugins: readonly PluginInfo[]) {
|
||||
return plugins.filter((plugin) => plugin.source.type !== "builtin").map(pluginLabel)
|
||||
}
|
||||
|
||||
@@ -160,7 +160,29 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
}),
|
||||
Spec.make("plugin", {
|
||||
description: "Manage plugins",
|
||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||
commands: [
|
||||
Spec.make("list", {
|
||||
description: "List plugins",
|
||||
params: {
|
||||
builtin: Flag.boolean("builtin").pipe(
|
||||
Flag.withDescription("Include built-in server plugins"),
|
||||
Flag.withDefault(false),
|
||||
),
|
||||
},
|
||||
}),
|
||||
Spec.make("add", {
|
||||
description: "Install a plugin and add it to the global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("npm registry package specifier")),
|
||||
},
|
||||
}),
|
||||
Spec.make("remove", {
|
||||
description: "Remove a plugin from global configuration",
|
||||
params: {
|
||||
package: Argument.string("package").pipe(Argument.withDescription("configured package specifier")),
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("models", {
|
||||
description: "List all available models",
|
||||
|
||||
@@ -84,8 +84,12 @@ export default Runtime.handler(Commands, (input) =>
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
packages: {
|
||||
resolve: (spec) =>
|
||||
runPromise(npm.add(spec, { subpaths: ["tui"] }).pipe(Effect.map((result) => result.entrypoint))),
|
||||
resolve: (spec, install = true) =>
|
||||
runPromise(
|
||||
(install ? npm.add(spec, { subpaths: ["tui"] }) : npm.resolve(spec, { subpaths: ["tui"] })).pipe(
|
||||
Effect.map((result) => result.entrypoint),
|
||||
),
|
||||
),
|
||||
},
|
||||
environment: requestedServer === undefined ? Env.session() : undefined,
|
||||
terminalHandoff: () => preflight.finish(),
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
import { Config } from "../../../config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.add,
|
||||
Effect.fn("cli.plugin.add")(function* (input) {
|
||||
if (!(yield* Effect.promise(() => Npm.isRegistryPackage(input.package))))
|
||||
return yield* Effect.fail(
|
||||
new Error("Plugin target must be an npm registry package name, version, tag, or semver range"),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(input.package, { subpaths: ["server", ""] })
|
||||
const tui = yield* npm.resolve(input.package, { subpaths: ["tui"] })
|
||||
const target = configurationTarget(installed.entrypoint, tui.entrypoint)
|
||||
if (!target)
|
||||
return yield* Effect.fail(new Error(`Plugin package has no server or TUI entrypoint: ${input.package}`))
|
||||
|
||||
if (target === "server") {
|
||||
const global = yield* Global.Service
|
||||
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
|
||||
const changed = yield* Effect.promise(() => writePluginConfig(configPath, input.package))
|
||||
process.stdout.write(
|
||||
changed
|
||||
? `Plugin "${input.package}" installed and added to ${configPath}${EOL}`
|
||||
: `Plugin "${input.package}" is already configured in ${configPath}${EOL}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const config = yield* Config.Service
|
||||
yield* config.update((draft) => {
|
||||
if (configured(draft.plugins, input.package)) return
|
||||
draft.plugins = [...(draft.plugins ?? []), input.package]
|
||||
})
|
||||
process.stdout.write(`TUI plugin "${input.package}" installed and added to ${config.path}${EOL}`)
|
||||
}),
|
||||
)
|
||||
|
||||
export function configurationTarget(server?: string, tui?: string) {
|
||||
if (server) return "server" as const
|
||||
if (tui) return "tui" as const
|
||||
}
|
||||
|
||||
export async function writePluginConfig(configPath: string, spec: string) {
|
||||
const text = await readFile(configPath, "utf8").catch((error) => {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "{}"
|
||||
throw error
|
||||
})
|
||||
const errors: ParseError[] = []
|
||||
const config: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
|
||||
throw new Error(`Invalid global configuration: ${configPath}`)
|
||||
const plugins = "plugins" in config ? config.plugins : undefined
|
||||
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
|
||||
if (configured(plugins, spec)) return false
|
||||
|
||||
const updated = applyEdits(
|
||||
text,
|
||||
modify(text, ["plugins"], [...(plugins ?? []), spec], { formattingOptions: { tabSize: 2, insertSpaces: true } }),
|
||||
)
|
||||
await mkdir(path.dirname(configPath), { recursive: true })
|
||||
const temporary = configPath + ".tmp"
|
||||
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
|
||||
await rename(temporary, configPath)
|
||||
return true
|
||||
}
|
||||
|
||||
function configured(plugins: readonly unknown[] | undefined, spec: string) {
|
||||
return plugins?.some(
|
||||
(entry) =>
|
||||
entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec),
|
||||
)
|
||||
}
|
||||
@@ -5,24 +5,69 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { discoverTuiPlugins, tuiPluginDirectories } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
Effect.fn("cli.plugin.list")(function* () {
|
||||
Effect.fn("cli.plugin.list")(function* (input) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const plugins = response.data.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
if (plugins.length === 0) {
|
||||
process.stdout.write("No plugins loaded" + EOL)
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const info = yield* config.get()
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
tuiPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
)
|
||||
const output = format(
|
||||
response.data,
|
||||
[
|
||||
...(info.plugins ?? []).flatMap((entry) => {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
return target.startsWith("-") ? [] : [{ target, source: "configured" as const }]
|
||||
}),
|
||||
...discovered.map((target) => ({ target, source: "discovered" as const })),
|
||||
],
|
||||
input.builtin,
|
||||
)
|
||||
if (!output) {
|
||||
process.stdout.write("No plugins found" + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(plugins.map(name).join(EOL) + EOL)
|
||||
process.stdout.write(output + EOL)
|
||||
}),
|
||||
)
|
||||
|
||||
export function format(
|
||||
plugins: readonly PluginInfo[],
|
||||
tui: ReadonlyArray<{ readonly target: string; readonly source: "configured" | "discovered" }>,
|
||||
builtin = false,
|
||||
) {
|
||||
const server = plugins
|
||||
.filter((plugin) => builtin || plugin.source.type !== "builtin")
|
||||
.toSorted((a, b) => name(a).localeCompare(name(b)))
|
||||
.map((plugin) => `${name(plugin)} (${plugin.status})`)
|
||||
const advertised = plugins.flatMap((plugin) =>
|
||||
plugin.status === "active" && plugin.tui && plugin.source.type === "package"
|
||||
? [{ target: plugin.source.package, source: "advertised" as const }]
|
||||
: [],
|
||||
)
|
||||
const targets = [...tui, ...advertised]
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
.toSorted((a, b) => a.target.localeCompare(b.target))
|
||||
.map((plugin) => `${plugin.target} (${plugin.source})`)
|
||||
return [
|
||||
targets.length ? ["TUI", ...targets].join(EOL) : undefined,
|
||||
server.length ? ["Server", ...server].join(EOL) : undefined,
|
||||
]
|
||||
.filter((section) => section !== undefined)
|
||||
.join(EOL + EOL)
|
||||
}
|
||||
|
||||
function name(plugin: PluginInfo) {
|
||||
if (plugin.id) return plugin.id
|
||||
if (plugin.source.type === "package") return plugin.source.package
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { EOL } from "node:os"
|
||||
import path from "node:path"
|
||||
import { readFile, rename, writeFile } from "node:fs/promises"
|
||||
import { Effect } from "effect"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { Config } from "../../../config"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.remove,
|
||||
Effect.fn("cli.plugin.remove")(function* (input) {
|
||||
const global = yield* Global.Service
|
||||
const configPath = yield* Effect.promise(() => resolveConfigPath(global.config))
|
||||
const server = yield* Effect.promise(() => removePluginConfig(configPath, input.package))
|
||||
const config = yield* Config.Service
|
||||
const info = yield* config.get()
|
||||
const tui = configured(info.plugins, input.package)
|
||||
if (tui)
|
||||
yield* config.update((draft) => {
|
||||
draft.plugins = draft.plugins?.filter((entry) => !matches(entry, input.package))
|
||||
})
|
||||
|
||||
const removed = [server ? configPath : undefined, tui ? config.path : undefined].filter(
|
||||
(file) => file !== undefined,
|
||||
)
|
||||
process.stdout.write(
|
||||
removed.length
|
||||
? `Plugin "${input.package}" removed from ${removed.join(", ")}${EOL}`
|
||||
: `Plugin "${input.package}" is not configured${EOL}`,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export async function removePluginConfig(configPath: string, spec: string) {
|
||||
const text = await readFile(configPath, "utf8").catch((error) => {
|
||||
if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return undefined
|
||||
throw error
|
||||
})
|
||||
if (text === undefined) return false
|
||||
const errors: ParseError[] = []
|
||||
const config: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof config !== "object" || config === null || Array.isArray(config))
|
||||
throw new Error(`Invalid global configuration: ${configPath}`)
|
||||
const plugins = "plugins" in config ? config.plugins : undefined
|
||||
if (plugins !== undefined && !Array.isArray(plugins)) throw new Error(`Invalid plugins configuration: ${configPath}`)
|
||||
if (!configured(plugins, spec)) return false
|
||||
|
||||
const updated = applyEdits(
|
||||
text,
|
||||
modify(
|
||||
text,
|
||||
["plugins"],
|
||||
plugins?.filter((entry) => !matches(entry, spec)),
|
||||
{
|
||||
formattingOptions: { tabSize: 2, insertSpaces: true },
|
||||
},
|
||||
),
|
||||
)
|
||||
const temporary = configPath + ".tmp"
|
||||
await writeFile(temporary, updated.endsWith("\n") ? updated : updated + "\n", { mode: 0o600 })
|
||||
await rename(temporary, configPath)
|
||||
return true
|
||||
}
|
||||
|
||||
function configured(plugins: readonly unknown[] | undefined, spec: string) {
|
||||
return plugins?.some((entry) => matches(entry, spec)) ?? false
|
||||
}
|
||||
|
||||
function matches(entry: unknown, spec: string) {
|
||||
return entry === spec || (typeof entry === "object" && entry !== null && "package" in entry && entry.package === spec)
|
||||
}
|
||||
@@ -38,6 +38,8 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
},
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
add: () => import("./commands/handlers/plugin/add"),
|
||||
remove: () => import("./commands/handlers/plugin/remove"),
|
||||
},
|
||||
models: () => import("./commands/handlers/models"),
|
||||
export: () => import("./commands/handlers/export"),
|
||||
|
||||
@@ -80,7 +80,13 @@ async function run(input: RunCommandInput, options: ExecutionOptions) {
|
||||
}
|
||||
|
||||
async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Endpoint, options: ExecutionOptions) {
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const client = OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
// Bun's default five-minute deadline terminates the event stream used by long-running sessions.
|
||||
fetch: ((request: RequestInfo | URL, init?: RequestInit) =>
|
||||
fetch(request, { ...init, timeout: false } as BunFetchRequestInit)) as typeof fetch,
|
||||
})
|
||||
const explicit = parseRunModel(input.model)
|
||||
const target = await resolveSessionTarget({
|
||||
client,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { configurationTarget, writePluginConfig } from "../src/commands/handlers/plugin/add"
|
||||
|
||||
test("routes packages according to their exported runtimes", () => {
|
||||
expect(configurationTarget("server.js", "tui.js")).toBe("server")
|
||||
expect(configurationTarget("server.js", undefined)).toBe("server")
|
||||
expect(configurationTarget(undefined, "tui.js")).toBe("tui")
|
||||
expect(configurationTarget(undefined, undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("adds a package to global plugin config without replacing unrelated settings", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "opencode.jsonc")
|
||||
await Bun.write(file, '{\n // retained\n "model": "provider/model",\n "plugins": ["first"]\n}\n')
|
||||
|
||||
try {
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(true)
|
||||
expect(await writePluginConfig(file, "second@1.0.0")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({
|
||||
model: "provider/model",
|
||||
plugins: ["first", "second@1.0.0"],
|
||||
})
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { EOL } from "node:os"
|
||||
import { format } from "../src/commands/handlers/plugin/list"
|
||||
|
||||
test("formats server and TUI plugins in sections without builtins", () => {
|
||||
expect(
|
||||
format(
|
||||
[
|
||||
{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false },
|
||||
{
|
||||
id: "acme.dual",
|
||||
source: { type: "package", package: "acme-plugin@1.0.0" },
|
||||
status: "active",
|
||||
tui: true,
|
||||
},
|
||||
{
|
||||
source: { type: "package", package: "broken-plugin" },
|
||||
status: "failed",
|
||||
error: "broken",
|
||||
tui: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ target: "tui-only", source: "configured" },
|
||||
{ target: "/tmp/local.ts", source: "discovered" },
|
||||
],
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
"TUI",
|
||||
"/tmp/local.ts (discovered)",
|
||||
"acme-plugin@1.0.0 (advertised)",
|
||||
"tui-only (configured)",
|
||||
"",
|
||||
"Server",
|
||||
"acme.dual (active)",
|
||||
"broken-plugin (failed)",
|
||||
].join(EOL),
|
||||
)
|
||||
})
|
||||
|
||||
test("includes builtins when requested", () => {
|
||||
expect(
|
||||
format([{ id: "opencode.agent", source: { type: "builtin" }, status: "active", tui: false }], [], true),
|
||||
).toBe(["Server", "opencode.agent (active)"].join(EOL))
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import path from "node:path"
|
||||
import { parse } from "jsonc-parser"
|
||||
import { removePluginConfig } from "../src/commands/handlers/plugin/remove"
|
||||
|
||||
test("removes string and object package entries without replacing unrelated settings", async () => {
|
||||
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
|
||||
const file = path.join(directory, "opencode.jsonc")
|
||||
await Bun.write(
|
||||
file,
|
||||
'{\n // retained\n "model": "provider/model",\n "plugins": ["remove-me", { "package": "remove-me", "options": {} }, "keep-me"]\n}\n',
|
||||
)
|
||||
|
||||
try {
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(true)
|
||||
expect(await removePluginConfig(file, "remove-me")).toBe(false)
|
||||
const text = await Bun.file(file).text()
|
||||
expect(text).toContain("// retained")
|
||||
expect(parse(text)).toEqual({ model: "provider/model", plugins: ["keep-me"] })
|
||||
} finally {
|
||||
await Bun.$`rm -rf ${directory}`
|
||||
}
|
||||
})
|
||||
@@ -579,6 +579,8 @@ export type Endpoint5_31Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly cost: number & Brand.Brand<"Money.USD">
|
||||
readonly tokens: {
|
||||
readonly input: number
|
||||
@@ -601,6 +603,9 @@ export type Endpoint5_31Output =
|
||||
readonly sessionID: Session.ID
|
||||
readonly assistantMessageID: SessionMessage.ID
|
||||
readonly error: { readonly type: string; readonly message: string; readonly status?: number | undefined }
|
||||
readonly finish?: "content-filter" | undefined
|
||||
readonly rawFinish?: string | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly cost?: (number & Brand.Brand<"Money.USD">) | undefined
|
||||
readonly tokens?:
|
||||
| {
|
||||
|
||||
@@ -1108,6 +1108,8 @@ export type SessionStepEnded = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
rawFinish?: string
|
||||
providerState?: SessionMessageProviderState1
|
||||
cost: MoneyUSD
|
||||
tokens: TokenUsageInfo
|
||||
snapshot?: string
|
||||
@@ -1145,6 +1147,9 @@ export type SessionStepFailed = {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
error: SessionStructuredError
|
||||
finish?: "content-filter"
|
||||
rawFinish?: string
|
||||
providerState?: SessionMessageProviderState1
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
snapshot?: string
|
||||
@@ -1921,6 +1926,8 @@ export type SessionMessageAssistant = {
|
||||
content: Array<SessionMessageAssistantText | SessionMessageAssistantReasoning | SessionMessageAssistantTool>
|
||||
snapshot?: { start?: string; end?: string; files?: Array<string> }
|
||||
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
rawFinish?: string
|
||||
providerState?: SessionMessageProviderState
|
||||
cost?: MoneyUSD
|
||||
tokens?: TokenUsageInfo
|
||||
error?: SessionStructuredError
|
||||
@@ -2691,6 +2698,8 @@ export type SessionImportInput = {
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -2958,6 +2967,8 @@ export type SessionImportInput = {
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
@@ -3225,6 +3236,8 @@ export type SessionImportInput = {
|
||||
>
|
||||
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
|
||||
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
|
||||
readonly rawFinish?: string
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly cost?: number
|
||||
readonly tokens?: {
|
||||
readonly input: number
|
||||
|
||||
@@ -601,6 +601,8 @@ export function createData(config: CreateDataInput) {
|
||||
existing.retry = undefined
|
||||
existing.error = undefined
|
||||
existing.finish = undefined
|
||||
existing.rawFinish = undefined
|
||||
existing.providerState = undefined
|
||||
existing.time.completed = undefined
|
||||
if (event.data.snapshot) existing.snapshot = { ...existing.snapshot, start: event.data.snapshot }
|
||||
return
|
||||
@@ -628,6 +630,8 @@ export function createData(config: CreateDataInput) {
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = event.data.finish
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
if (event.data.snapshot)
|
||||
@@ -640,7 +644,9 @@ export function createData(config: CreateDataInput) {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
currentAssistant.time.completed = event.created
|
||||
currentAssistant.finish = "error"
|
||||
currentAssistant.finish = event.data.finish ?? "error"
|
||||
currentAssistant.rawFinish = event.data.rawFinish
|
||||
currentAssistant.providerState = event.data.providerState
|
||||
currentAssistant.error = event.data.error
|
||||
currentAssistant.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
|
||||
@@ -195,6 +195,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
draft.retry = undefined
|
||||
draft.error = undefined
|
||||
draft.finish = undefined
|
||||
draft.rawFinish = undefined
|
||||
draft.providerState = undefined
|
||||
draft.time.completed = undefined
|
||||
if (event.data.snapshot) draft.snapshot = { ...draft.snapshot, start: event.data.snapshot }
|
||||
}),
|
||||
@@ -228,6 +230,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = created
|
||||
draft.finish = event.data.finish
|
||||
draft.rawFinish = event.data.rawFinish
|
||||
draft.providerState = castDraft(event.data.providerState)
|
||||
draft.cost = event.data.cost
|
||||
draft.tokens = event.data.tokens
|
||||
if (event.data.snapshot || event.data.files)
|
||||
@@ -241,7 +245,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.step.failed": (event) => {
|
||||
return updateOwnedAssistant(event.data.assistantMessageID, (draft) => {
|
||||
draft.time.completed = created
|
||||
draft.finish = "error"
|
||||
draft.finish = event.data.finish ?? "error"
|
||||
draft.rawFinish = event.data.rawFinish
|
||||
draft.providerState = castDraft(event.data.providerState)
|
||||
draft.error = castDraft(event.data.error)
|
||||
draft.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
|
||||
@@ -326,6 +326,8 @@ const layer = Layer.effect(
|
||||
sessionID: session.id,
|
||||
assistantMessageID: yield* publisher.startAssistant(),
|
||||
finish: finish.finish,
|
||||
rawFinish: finish.rawFinish,
|
||||
providerState: finish.providerState,
|
||||
...stepUsage(finish),
|
||||
...end,
|
||||
})
|
||||
|
||||
@@ -35,6 +35,8 @@ export interface StepRecord {
|
||||
/** Present once the provider finished the step normally. */
|
||||
readonly finish?: {
|
||||
readonly finish: Extract<LLMEvent, { type: "step-finish" }>["reason"]["normalized"]
|
||||
readonly rawFinish?: string
|
||||
readonly providerState?: SessionMessage.ProviderState
|
||||
readonly tokens: ReturnType<typeof SessionUsage.tokens>
|
||||
}
|
||||
readonly calls: ReadonlyArray<{
|
||||
@@ -364,6 +366,9 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
sessionID: input.sessionID,
|
||||
assistantMessageID,
|
||||
error: stepFailure,
|
||||
finish: stepSettlement?.finish === "content-filter" ? stepSettlement.finish : undefined,
|
||||
rawFinish: stepSettlement?.rawFinish,
|
||||
providerState: stepSettlement?.providerState,
|
||||
...details,
|
||||
})
|
||||
})
|
||||
@@ -517,7 +522,12 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
case "step-finish":
|
||||
yield* flush()
|
||||
if (stepSettlement) return yield* Effect.die(new Error("Duplicate step finish"))
|
||||
stepSettlement = { finish: event.reason.normalized, tokens: SessionUsage.tokens(event.usage) }
|
||||
stepSettlement = {
|
||||
finish: event.reason.normalized,
|
||||
rawFinish: event.reason.raw,
|
||||
providerState: providerState(event.providerMetadata),
|
||||
tokens: SessionUsage.tokens(event.usage),
|
||||
}
|
||||
if (event.reason.normalized === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({ type: "provider.content-filter", message: "Provider blocked the response" })
|
||||
|
||||
@@ -56,8 +56,8 @@ const headers = (format: Format, userAgent: string) => ({
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
})
|
||||
|
||||
const browserUserAgent =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
const openCodeUserAgent =
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
|
||||
|
||||
const isCloudflareChallenge = (error: unknown) => {
|
||||
if (!error || typeof error !== "object" || !("reason" in error)) return false
|
||||
@@ -74,14 +74,14 @@ const isCloudflareChallenge = (error: unknown) => {
|
||||
return response.status === 403 && response.headers["cf-mitigated"] === "challenge"
|
||||
}
|
||||
|
||||
const request = (url: string, format: Format, userAgent = browserUserAgent) =>
|
||||
const request = (url: string, format: Format, userAgent = openCodeUserAgent) =>
|
||||
HttpClientRequest.get(url).pipe(HttpClientRequest.setHeaders(headers(format, userAgent)))
|
||||
|
||||
const assertHttpUrl = (url: URL) => {
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("URL must use http:// or https://")
|
||||
}
|
||||
|
||||
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = browserUserAgent) =>
|
||||
const execute = (http: HttpClient.HttpClient, url: string, format: Format, userAgent = openCodeUserAgent) =>
|
||||
http.execute(request(url, format, userAgent)).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk))
|
||||
|
||||
const collectBody = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
|
||||
@@ -35,6 +35,17 @@ describe("Npm.sanitize", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.isRegistryPackage", () => {
|
||||
test("accepts registry packages and rejects unsupported install targets", async () => {
|
||||
expect(await Npm.isRegistryPackage("plugin")).toBe(true)
|
||||
expect(await Npm.isRegistryPackage("@acme/plugin@beta")).toBe(true)
|
||||
expect(await Npm.isRegistryPackage("plugin@^1.2.0")).toBe(true)
|
||||
expect(await Npm.isRegistryPackage("./plugin")).toBe(false)
|
||||
expect(await Npm.isRegistryPackage("github:acme/plugin")).toBe(false)
|
||||
expect(await Npm.isRegistryPackage("alias@npm:plugin@1.0.0")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.add", () => {
|
||||
test("resolves cached scoped package specs without reifying", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
@@ -106,3 +117,31 @@ describe("Npm.add", () => {
|
||||
expect(entries.fallback.entrypoint).toEndWith("/index.js")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Npm.resolve", () => {
|
||||
test("resolves a TUI entrypoint only when the package is already cached", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cache = path.join(tmp.path, "cache")
|
||||
const spec = "fixture-plugin@1.0.0"
|
||||
const directory = path.join(cache, "packages", Npm.sanitize(spec), "node_modules", "fixture-plugin")
|
||||
const missing = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(missing.entrypoint).toBeUndefined()
|
||||
|
||||
await fs.mkdir(directory, { recursive: true })
|
||||
await writePackage(directory, {
|
||||
name: "fixture-plugin",
|
||||
exports: { ".": "./index.js", "./tui": "./tui.js" },
|
||||
})
|
||||
await Bun.write(path.join(directory, "index.js"), "export default {}\n")
|
||||
await Bun.write(path.join(directory, "tui.js"), "export default {}\n")
|
||||
|
||||
const resolved = await Effect.gen(function* () {
|
||||
const npm = yield* Npm.Service
|
||||
return yield* npm.resolve(spec, { subpaths: ["tui"] })
|
||||
}).pipe(Effect.scoped, Effect.provide(npmLayer(cache)), Effect.runPromise)
|
||||
expect(resolved.entrypoint).toEndWith("/tui.js")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,6 +31,7 @@ const npmLayer = Layer.succeed(
|
||||
Npm.Service,
|
||||
Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.succeed(undefined),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ const itWithAISDK = testEffect(Layer.mergeAll(PluginTestLayer, AppNodeBuilder.bu
|
||||
function npmEntrypoint(entrypoint?: string) {
|
||||
return Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint }),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const fixtureProvider = new URL("./fixtures/provider-factory.ts", import.meta.ur
|
||||
const it = testEffect(PluginTestLayer)
|
||||
const npm = Npm.Service.of({
|
||||
add: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
resolve: () => Effect.succeed({ directory: "", entrypoint: undefined }),
|
||||
which: () => Effect.succeed(undefined),
|
||||
})
|
||||
|
||||
|
||||
@@ -353,7 +353,12 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
publisher.publish(
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: { normalized: "content-filter" },
|
||||
reason: { normalized: "content-filter", raw: "refusal" },
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
|
||||
},
|
||||
},
|
||||
usage: {
|
||||
nonCachedInputTokens: 8,
|
||||
outputTokens: 3,
|
||||
@@ -367,6 +372,10 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
const settlement = publisher.record().finish
|
||||
expect(settlement).toMatchObject({
|
||||
finish: "content-filter",
|
||||
rawFinish: "refusal",
|
||||
providerState: {
|
||||
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
|
||||
},
|
||||
tokens: { input: 8, output: 2, reasoning: 1 },
|
||||
})
|
||||
if (!settlement) throw new Error("Expected content-filter settlement")
|
||||
@@ -381,6 +390,11 @@ test("content-filter finish retains failure evidence until step closeout", async
|
||||
expect(published.map((event) => event.type)).toEqual(["session.step.started.1", "session.step.failed.1"])
|
||||
expect(published.at(-1)?.data).toMatchObject({
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
finish: "content-filter",
|
||||
rawFinish: "refusal",
|
||||
providerState: {
|
||||
stopDetails: { type: "refusal", category: "safety", explanation: "Blocked" },
|
||||
},
|
||||
cost: 1.25,
|
||||
tokens: { input: 8, output: 2, reasoning: 1 },
|
||||
snapshot: "tree-end",
|
||||
|
||||
@@ -4161,13 +4161,49 @@ describe("SessionRunnerLLM", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("persists raw finish reasons and provider state", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* TestLLM.push(
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "stop", raw: "end_turn" },
|
||||
providerMetadata: { openai: { responseId: "response-1", serviceTier: "priority" } },
|
||||
},
|
||||
LLMEvent.textStart({ id: "answer" }),
|
||||
LLMEvent.textDelta({ id: "answer", text: "Complete" }),
|
||||
LLMEvent.textEnd({ id: "answer" }),
|
||||
),
|
||||
)
|
||||
|
||||
yield* runPrompt(session, "Keep provider finish details")
|
||||
|
||||
expect(yield* session.context(sessionID)).toMatchObject([
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "stop",
|
||||
rawFinish: "end_turn",
|
||||
providerState: { responseId: "response-1", serviceTier: "priority" },
|
||||
content: [{ type: "text", text: "Complete" }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects content-filter finishes as visible terminal failures", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* setup
|
||||
yield* TestLLM.push(
|
||||
TestLLM.complete(
|
||||
{
|
||||
reason: { normalized: "content-filter" },
|
||||
reason: { normalized: "content-filter", raw: "SAFETY" },
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
responseId: "response-blocked",
|
||||
refusal: { category: "safety", explanation: "Prompt blocked" },
|
||||
},
|
||||
},
|
||||
usage: { nonCachedInputTokens: 8, outputTokens: 3, reasoningTokens: 1 },
|
||||
},
|
||||
LLMEvent.textStart({ id: "partial" }),
|
||||
@@ -4182,7 +4218,12 @@ describe("SessionRunnerLLM", () => {
|
||||
{ type: "user" },
|
||||
{
|
||||
type: "assistant",
|
||||
finish: "error",
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
providerState: {
|
||||
responseId: "response-blocked",
|
||||
refusal: { category: "safety", explanation: "Prompt blocked" },
|
||||
},
|
||||
error: { type: "provider.content-filter" },
|
||||
cost: 0,
|
||||
tokens: { input: 8, output: 2, reasoning: 1, cache: { read: 0, write: 0 } },
|
||||
|
||||
@@ -23,6 +23,8 @@ const webFetchToolNode = makeLocationNode({
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_webfetch_test")
|
||||
const webFetchUserAgent =
|
||||
"Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; OpenCode-User/1.0; +https://opencode.ai"
|
||||
const requests: Array<{ readonly url: string; readonly headers: Record<string, string> }> = []
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
let respond = (_request: HttpClientRequest.HttpClientRequest) =>
|
||||
@@ -376,7 +378,17 @@ describe("WebFetchTool registration", () => {
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text", timeout: 4 } },
|
||||
])
|
||||
expect(requests).toMatchObject([{ url, headers: { accept: expect.stringContaining("text/plain;q=1.0") } }])
|
||||
expect(requests).toMatchObject([
|
||||
{
|
||||
url,
|
||||
headers: {
|
||||
accept: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"user-agent": webFetchUserAgent,
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(requests[0]?.headers).not.toHaveProperty("sec-fetch-mode")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -397,15 +409,23 @@ describe("WebFetchTool registration", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
live.effect("follows redirects while approving only the requested URL", () =>
|
||||
Effect.acquireUseRelease(
|
||||
live.effect("follows redirects while approving only the requested URL", () => {
|
||||
const received: Array<Record<string, string | null>> = []
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/redirect"
|
||||
? new Response("", { status: 302, headers: { location: "/target" } })
|
||||
: new Response("redirected", { headers: { "content-type": "text/plain" } }),
|
||||
fetch: (request) => {
|
||||
received.push({
|
||||
accept: request.headers.get("accept"),
|
||||
"accept-language": request.headers.get("accept-language"),
|
||||
"sec-fetch-mode": request.headers.get("sec-fetch-mode"),
|
||||
"user-agent": request.headers.get("user-agent"),
|
||||
})
|
||||
if (new URL(request.url).pathname === "/redirect")
|
||||
return new Response("", { status: 302, headers: { location: "/target" } })
|
||||
return new Response("redirected", { headers: { "content-type": "text/plain" } })
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) =>
|
||||
@@ -421,10 +441,18 @@ describe("WebFetchTool registration", () => {
|
||||
expect(assertions).toMatchObject([
|
||||
{ sessionID, action: "webfetch", resources: [url], save: ["*"], metadata: { url, format: "text" } },
|
||||
])
|
||||
expect(received).toEqual(
|
||||
Array.from({ length: 2 }, () => ({
|
||||
accept: "text/plain;q=1.0, text/markdown;q=0.9, text/html;q=0.8, */*;q=0.1",
|
||||
"accept-language": "en-US,en;q=0.9",
|
||||
"sec-fetch-mode": null,
|
||||
"user-agent": webFetchUserAgent,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
(server) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("rejects non-HTTP schemes before permission or transport", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -549,7 +577,7 @@ describe("WebFetchTool registration", () => {
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
})
|
||||
expect(requests).toHaveLength(2)
|
||||
expect(requests[0]?.headers["user-agent"]).toContain("Mozilla/5.0")
|
||||
expect(requests[0]?.headers["user-agent"]).toBe(webFetchUserAgent)
|
||||
expect(requests[1]?.headers["user-agent"]).toBe("opencode")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -43,14 +43,16 @@ export async function startBackgroundCli(logger: Logger) {
|
||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||
})
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
const url = new URL(service.url)
|
||||
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
|
||||
logger.log("v2 CLI background service ready", {
|
||||
username: service.auth.username,
|
||||
version: cli.version,
|
||||
...endpoint(service.url),
|
||||
...endpoint(url.origin),
|
||||
})
|
||||
if (isolated && cli.binary) await cleanCliStages(cli.binary, logger)
|
||||
return {
|
||||
url: service.url,
|
||||
url: url.origin,
|
||||
username: service.auth.username,
|
||||
password: service.auth.password,
|
||||
version: cli.version,
|
||||
|
||||
@@ -298,6 +298,8 @@ export namespace Step {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
finish: FinishReason,
|
||||
rawFinish: Schema.String.pipe(optional),
|
||||
providerState: SessionMessage.ProviderState.pipe(optional),
|
||||
cost: Money.USD,
|
||||
tokens: TokenUsage.Info,
|
||||
snapshot: Snapshot.ID.pipe(optional),
|
||||
@@ -313,6 +315,9 @@ export namespace Step {
|
||||
...Base,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
error: SessionError.Error,
|
||||
finish: Schema.Literals(["content-filter"]).pipe(optional),
|
||||
rawFinish: Schema.String.pipe(optional),
|
||||
providerState: SessionMessage.ProviderState.pipe(optional),
|
||||
cost: Money.USD.pipe(optional),
|
||||
tokens: TokenUsage.Info.pipe(optional),
|
||||
snapshot: Snapshot.ID.pipe(optional),
|
||||
|
||||
@@ -215,6 +215,8 @@ export const Assistant = Schema.Struct({
|
||||
files: Schema.Array(RelativePath).pipe(optional),
|
||||
}).pipe(optional),
|
||||
finish: FinishReason.pipe(optional),
|
||||
rawFinish: Schema.String.pipe(optional),
|
||||
providerState: ProviderState.pipe(optional),
|
||||
cost: Money.USD.pipe(optional),
|
||||
tokens: TokenUsage.Info.pipe(optional),
|
||||
error: SessionError.Error.pipe(optional),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { SessionEvent } from "../src/session-event.js"
|
||||
import { SessionMessage } from "../src/session-message.js"
|
||||
|
||||
const assistant = {
|
||||
id: "msg_terminal",
|
||||
type: "assistant" as const,
|
||||
agent: "build",
|
||||
model: { providerID: "openai", id: "gpt-test" },
|
||||
content: [],
|
||||
time: { created: 0 },
|
||||
}
|
||||
|
||||
test("assistant terminal diagnostics remain optional and round trip", () => {
|
||||
const decode = Schema.decodeUnknownSync(SessionMessage.Assistant)
|
||||
const encode = Schema.encodeSync(SessionMessage.Assistant)
|
||||
|
||||
expect(encode(decode(assistant))).toEqual(assistant)
|
||||
expect(
|
||||
encode(
|
||||
decode({
|
||||
...assistant,
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
providerState: { promptFeedback: { blockReason: "SAFETY" } },
|
||||
}),
|
||||
),
|
||||
).toMatchObject({
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
providerState: { promptFeedback: { blockReason: "SAFETY" } },
|
||||
})
|
||||
})
|
||||
|
||||
test("failed steps only override the assistant finish for content filters", () => {
|
||||
const decode = Schema.decodeUnknownSync(SessionEvent.Step.Failed.data)
|
||||
const input = {
|
||||
sessionID: "ses_terminal",
|
||||
assistantMessageID: "msg_terminal",
|
||||
error: { type: "provider.content-filter", message: "Blocked" },
|
||||
}
|
||||
|
||||
expect(decode(input)).toMatchObject(input)
|
||||
expect(decode({ ...input, finish: "content-filter", rawFinish: "SAFETY" })).toMatchObject({
|
||||
finish: "content-filter",
|
||||
rawFinish: "SAFETY",
|
||||
})
|
||||
expect(() => decode({ ...input, finish: "stop" })).toThrow()
|
||||
})
|
||||
@@ -24,6 +24,7 @@
|
||||
"./context/client": "./src/context/client.tsx",
|
||||
"./context/theme": "./src/context/theme.tsx",
|
||||
"./theme/discovery": "./src/theme/discovery.ts",
|
||||
"./plugin/discovery": "./src/plugin/discovery.ts",
|
||||
"./context/editor": "./src/context/editor.ts",
|
||||
"./context/clipboard": "./src/context/clipboard.tsx",
|
||||
"./attention": "./src/attention.ts",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createMarkdownCodeBlockRenderer, type MarkdownCodeBlockRenderer, type MarkdownOptions } from "@opentui/core"
|
||||
import {
|
||||
@@ -5,6 +6,7 @@ import {
|
||||
createContext,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
on,
|
||||
onCleanup,
|
||||
onMount,
|
||||
@@ -21,6 +23,8 @@ import { isDeepEqual } from "remeda"
|
||||
import "#runtime-plugin-support"
|
||||
import { useConfig } from "../config"
|
||||
import { useTuiLifecycle } from "../context/runtime"
|
||||
import { useClient } from "../context/client"
|
||||
import { useData } from "../context/data"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { builtins } from "./builtins"
|
||||
import { createPluginContext, usePluginHost, type Dispose, type RegisteredSlot, type SlotRender } from "./api"
|
||||
@@ -28,7 +32,7 @@ import { createSourceWatcher } from "./watch"
|
||||
import { discoverTuiPlugins, freshSpecifier, localSource } from "./discovery"
|
||||
|
||||
export interface PackageResolver {
|
||||
readonly resolve: (spec: string) => Promise<string | undefined>
|
||||
readonly resolve: (spec: string, install?: boolean) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
type State =
|
||||
@@ -90,6 +94,13 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const host = usePluginHost()
|
||||
const config = useConfig()
|
||||
const lifecycle = useTuiLifecycle()
|
||||
const client = useClient()
|
||||
const data = useData()
|
||||
const [serverPlugins, setServerPlugins] = createSignal<
|
||||
ReadonlyArray<
|
||||
Extract<PluginInfo, { readonly status: "active" }> & { readonly source: { readonly type: "package" } }
|
||||
>
|
||||
>([])
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
@@ -230,7 +241,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const npmFailures = new Map<string, string>()
|
||||
const reconcile = async () => {
|
||||
await Promise.all(props.directories.map(watcher.wait))
|
||||
const entries = [...(await discoverTuiPlugins(props.directories)), ...(config.data.plugins ?? [])]
|
||||
const entries = [
|
||||
...(await discoverTuiPlugins(props.directories)).map((entry) => ({ entry, install: true, server: false })),
|
||||
...serverPlugins().map((plugin) => ({ entry: plugin.source.package, install: false, server: true })),
|
||||
...(config.data.plugins ?? []).map((entry) => ({ entry, install: true, server: false })),
|
||||
]
|
||||
|
||||
// Resolve: fold entries into one desired generation. A source that fails
|
||||
// to import keeps its running previous version and only reports failure.
|
||||
@@ -238,7 +253,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
for (const plugin of builtins)
|
||||
desired.set(plugin.id, { plugin, source: "builtin", version: "builtin", enabled: true })
|
||||
const failures: State[] = []
|
||||
for (const entry of entries) {
|
||||
for (const source of entries) {
|
||||
const entry = source.entry
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-")) {
|
||||
for (const item of desired.values()) if (matches(target.slice(1), item.plugin.id)) item.enabled = false
|
||||
@@ -259,11 +275,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const memo = local ? undefined : npmFailures.get(target)
|
||||
const resolved = memo
|
||||
? { status: "failed" as const, error: memo }
|
||||
: await resolvePlugin(target, local, options, previous, props.packages).catch((error) => ({
|
||||
: await resolvePlugin(target, local, options, previous, props.packages, source.install).catch((error) => ({
|
||||
status: "failed" as const,
|
||||
error: errorMessage(error),
|
||||
}))
|
||||
if (resolved.status === "unsupported") {
|
||||
if (source.server) continue
|
||||
failures.push({ target, status: "unsupported" })
|
||||
continue
|
||||
}
|
||||
@@ -439,7 +456,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() }))
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(config.data.plugins ?? []),
|
||||
() => JSON.stringify([serverPlugins(), config.data.plugins ?? []]),
|
||||
() => {
|
||||
npmFailures.clear()
|
||||
void enqueue(reconcile).then(
|
||||
@@ -449,6 +466,29 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d
|
||||
},
|
||||
),
|
||||
)
|
||||
const syncServerPlugins = () =>
|
||||
client.api.plugin
|
||||
.list({ location: data.location.default() })
|
||||
.then((response) =>
|
||||
setServerPlugins(
|
||||
response.data.filter(
|
||||
(
|
||||
plugin,
|
||||
): plugin is Extract<PluginInfo, { readonly status: "active" }> & {
|
||||
readonly source: { readonly type: "package" }
|
||||
} => plugin.status === "active" && plugin.tui && plugin.source.type === "package",
|
||||
),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
createEffect(
|
||||
on(
|
||||
() => JSON.stringify(data.location.default()),
|
||||
() => void syncServerPlugins(),
|
||||
),
|
||||
)
|
||||
onCleanup(client.event.on("plugin.updated", syncServerPlugins))
|
||||
onCleanup(client.event.on("server.connected", syncServerPlugins))
|
||||
onMount(() => {
|
||||
let disposing: Promise<void> | undefined
|
||||
const dispose = () => {
|
||||
@@ -523,12 +563,13 @@ async function resolvePlugin(
|
||||
options: Readonly<Record<string, any>> | undefined,
|
||||
previous: Registration | undefined,
|
||||
packages: PackageResolver,
|
||||
install: boolean,
|
||||
) {
|
||||
// Package entrypoints never change within a session, so a loaded previous
|
||||
// version needs no re-resolution (which could otherwise hit npm).
|
||||
if (!local && previous && sameOptions(previous.options, options))
|
||||
return { status: "unchanged" as const, plugin: previous.plugin, version: previous.version }
|
||||
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec)
|
||||
const entrypoint = local ? await resolveLocal(local) : await packages.resolve(spec, install)
|
||||
if (!entrypoint) return { status: "unsupported" as const }
|
||||
// The cache-busted specifier doubles as the version: unique per entrypoint
|
||||
// and mtime, so equal versions mean an identical module.
|
||||
|
||||
@@ -95,6 +95,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory, project: { id: "proj_test", directory: worktree, canonical: worktree } })
|
||||
if (url.pathname === "/api/plugin")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
data: [],
|
||||
})
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Effect, FileSystem } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { createEventStream, createFetch, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
@@ -30,12 +31,26 @@ async function until(read: () => Promise<string>, expected: (value: string | und
|
||||
return value
|
||||
}
|
||||
|
||||
async function bootApp(directory: string) {
|
||||
async function bootApp(
|
||||
directory: string,
|
||||
options?: {
|
||||
plugins?: unknown[]
|
||||
resolve?: (spec: string, install?: boolean) => Promise<string | undefined>
|
||||
},
|
||||
) {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/plugin")
|
||||
return json({
|
||||
location: {
|
||||
directory,
|
||||
project: { id: "proj_test", directory, canonical: directory },
|
||||
},
|
||||
data: options?.plugins ?? [],
|
||||
})
|
||||
if (url.pathname !== "/api/fs/list") return
|
||||
return json({
|
||||
location: {
|
||||
@@ -54,7 +69,7 @@ async function bootApp(directory: string) {
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
packages: { resolve: options?.resolve ?? (async () => undefined) },
|
||||
args: {},
|
||||
log: () => {},
|
||||
}).pipe(
|
||||
@@ -73,6 +88,40 @@ async function bootApp(directory: string) {
|
||||
}
|
||||
}
|
||||
|
||||
test("loads an advertised package TUI entrypoint only from the local cache", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const marker = path.join(tmp.path, "marker.txt")
|
||||
const entrypoint = path.join(tmp.path, "tui.ts")
|
||||
await writeFile(entrypoint, lifecycleSource(marker, "test.package", "package"))
|
||||
const resolutions: Array<{ spec: string; install?: boolean }> = []
|
||||
|
||||
await using app = await bootApp(tmp.path, {
|
||||
plugins: [
|
||||
{
|
||||
id: "test.server",
|
||||
source: { type: "package", package: "test-plugin@1.0.0" },
|
||||
status: "active",
|
||||
tui: true,
|
||||
},
|
||||
],
|
||||
resolve: async (spec, install) => {
|
||||
resolutions.push({ spec, install })
|
||||
return pathToFileURL(entrypoint).href
|
||||
},
|
||||
})
|
||||
|
||||
expect(
|
||||
await until(
|
||||
() => readFile(marker, "utf8"),
|
||||
(value) => value === "package:setup\n",
|
||||
),
|
||||
).toBe("package:setup\n")
|
||||
expect(resolutions).toContainEqual({ spec: "test-plugin@1.0.0", install: false })
|
||||
|
||||
process.emit("SIGHUP")
|
||||
await app.task
|
||||
})
|
||||
|
||||
test("discovers an ancestor TUI plugin directory created after startup", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cwd = path.join(tmp.path, "repo", "packages", "app")
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface Interface {
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) => Effect.Effect<EntryPoint, InstallFailedError | EffectFlock.LockError>
|
||||
readonly resolve: (pkg: string, options?: { readonly subpaths?: readonly string[] }) => Effect.Effect<EntryPoint>
|
||||
readonly which: (pkg: string, bin?: string) => Effect.Effect<string | undefined>
|
||||
}
|
||||
|
||||
@@ -41,6 +42,16 @@ export function sanitize(pkg: string) {
|
||||
return Array.from(pkg, (char) => (illegal.has(char) || char.charCodeAt(0) < 32 ? "_" : char)).join("")
|
||||
}
|
||||
|
||||
export async function isRegistryPackage(pkg: string) {
|
||||
const { default: npa } = await import("npm-package-arg")
|
||||
try {
|
||||
const result = npa(pkg)
|
||||
return result.name !== undefined && ["version", "range", "tag"].includes(result.type)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const resolveEntryPoint = (name: string, dir: string, subpaths: readonly string[] = [""]): EntryPoint => {
|
||||
const entrypoint = subpaths
|
||||
.map((subpath) => {
|
||||
@@ -134,6 +145,23 @@ const layer = Layer.effect(
|
||||
return resolveEntryPoint(first.name, first.path, options?.subpaths)
|
||||
}, Effect.scoped)
|
||||
|
||||
const resolve = Effect.fn("Npm.resolve")(function* (
|
||||
pkg: string,
|
||||
options?: { readonly subpaths?: readonly string[] },
|
||||
) {
|
||||
const { default: npa } = yield* Effect.promise(() => import("npm-package-arg"))
|
||||
const name = (() => {
|
||||
try {
|
||||
return npa(pkg).name ?? pkg
|
||||
} catch {
|
||||
return pkg
|
||||
}
|
||||
})()
|
||||
const dir = path.join(directory(pkg), "node_modules", name)
|
||||
if (!(yield* afs.existsSafe(dir))) return { directory: dir }
|
||||
return resolveEntryPoint(name, dir, options?.subpaths)
|
||||
})
|
||||
|
||||
const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
|
||||
const dir = directory(pkg)
|
||||
const binDir = path.join(dir, "node_modules", ".bin")
|
||||
@@ -187,6 +215,7 @@ const layer = Layer.effect(
|
||||
|
||||
return Service.of({
|
||||
add,
|
||||
resolve,
|
||||
which,
|
||||
})
|
||||
}),
|
||||
@@ -204,6 +233,10 @@ export async function add(...args: Parameters<Interface["add"]>) {
|
||||
return runPromise((svc) => svc.add(...args))
|
||||
}
|
||||
|
||||
export async function resolve(...args: Parameters<Interface["resolve"]>) {
|
||||
return runPromise((svc) => svc.resolve(...args))
|
||||
}
|
||||
|
||||
export async function which(...args: Parameters<Interface["which"]>) {
|
||||
return runPromise((svc) => svc.which(...args))
|
||||
}
|
||||
|
||||
+35
-1
@@ -99,6 +99,32 @@ an isolated cache. Package installation does not run lifecycle scripts.
|
||||
Published packages should expose their plugin entrypoint and include every
|
||||
runtime import in `dependencies`.
|
||||
|
||||
Install a package plugin globally with the CLI:
|
||||
|
||||
```sh
|
||||
opencode2 plugin add opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
This installs and inspects the package before changing configuration. Packages
|
||||
with a server entrypoint are added to global `opencode.json(c)`. Packages that
|
||||
only expose `./tui` are added to global `cli.json` instead.
|
||||
|
||||
The command accepts npm registry package names with an optional version,
|
||||
dist-tag, or semver range. Configure local paths directly instead; Git, tarball,
|
||||
and npm alias targets are not accepted by `plugin add`.
|
||||
|
||||
List configured and active plugins, or remove a package from both global server
|
||||
and TUI configuration:
|
||||
|
||||
```sh
|
||||
opencode2 plugin list
|
||||
opencode2 plugin list --builtin
|
||||
opencode2 plugin remove opencode-acme-plugin@1.2.0
|
||||
```
|
||||
|
||||
Built-in server plugins are hidden from the default list. Removing a plugin
|
||||
keeps its package cache available for later reuse.
|
||||
|
||||
Local files and local package directories are imported directly. OpenCode does
|
||||
**not** install their dependencies. Install dependencies in a `package.json`
|
||||
visible from the plugin file, for example:
|
||||
@@ -397,13 +423,21 @@ manifest is:
|
||||
"name": "opencode-acme-plugin",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tui": "./src/tui.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "beta"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Packages with a TUI entrypoint should set `tui: true` on their server plugin
|
||||
definition. A locally connected TUI loads the package's `./tui` export from the
|
||||
existing OpenCode package cache. A TUI connected to a remote server skips it
|
||||
when that package is not installed locally.
|
||||
|
||||
Use versions compatible with the OpenCode release you target and test the
|
||||
installed package, not only a workspace-linked copy. Because the plugin API is
|
||||
beta, publish compatible plugin updates when V2 entrypoints or contracts
|
||||
|
||||
Reference in New Issue
Block a user