mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-02 23:16:21 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6948a3f5f5 |
@@ -47,7 +47,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Deploy update service
|
||||
if: github.ref_name == 'v2'
|
||||
if: github.ref_name == 'v2' || github.ref_name == 'beta'
|
||||
working-directory: packages/updates
|
||||
run: bun run deploy
|
||||
env:
|
||||
|
||||
@@ -575,7 +575,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"zod": "catalog:",
|
||||
@@ -586,7 +585,6 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -973,6 +971,7 @@
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"pacote": "21.5.1",
|
||||
"resolve.exports": "catalog:",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/bun": "catalog:",
|
||||
@@ -1124,6 +1123,7 @@
|
||||
"opentui-spinner": "0.0.7",
|
||||
"remeda": "2.26.0",
|
||||
"remend": "1.3.1",
|
||||
"resolve.exports": "2.0.3",
|
||||
"semver": "7.7.4",
|
||||
"shiki": "4.4.3",
|
||||
"solid-js": "1.9.15",
|
||||
@@ -5171,6 +5171,8 @@
|
||||
|
||||
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
|
||||
|
||||
"resolve.exports": ["resolve.exports@2.0.3", "", {}, "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A=="],
|
||||
|
||||
"responselike": ["responselike@2.0.1", "", { "dependencies": { "lowercase-keys": "^2.0.0" } }, "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw=="],
|
||||
|
||||
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-j3Vtfd5sx+hHnV3qHBq7Ae96E08meNqTqtYN9kWmCqE=",
|
||||
"aarch64-linux": "sha256-LCvHEYoDgvkXtFFs0u7XBEzyo8fGGX25N8iuLrHSu8I=",
|
||||
"aarch64-darwin": "sha256-wBRx1e8x6toEHV1sFMDDs26+s8IHyPAqCg6TcBgsxxA=",
|
||||
"x86_64-darwin": "sha256-ClVls/CTpT76W5SQ/cij2YFmBqv9FGLeVxQh8iStnEk="
|
||||
"x86_64-linux": "sha256-Vazzj75ji5YbLtX5X0q+IZvygGrfmEi3+PzJsAkaa3s=",
|
||||
"aarch64-linux": "sha256-QFKzH7wlRcvYG4EHQgQ+++9zNQ+Mw5DVKembGiMjiCg=",
|
||||
"aarch64-darwin": "sha256-WAYBBA2jLW95wM8Dgk77KsO3yS5efxQs9oBVAcdx8Gc=",
|
||||
"x86_64-darwin": "sha256-c9P+VQvfdOu5ef6BnKXb3/IUo8pPFaYQv8B+ijofm1s="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
|
||||
"postinstall": "bun run --cwd packages/core fix-node-pty",
|
||||
"prepare": "husky",
|
||||
"reserve-packages": "bun script/reserve-package-names.ts",
|
||||
"random": "echo 'Random script'",
|
||||
"sso": "aws sso login --sso-session=opencode --no-browser",
|
||||
"test": "echo 'do not run tests from root' && exit 1"
|
||||
@@ -92,6 +93,7 @@
|
||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||
"zod": "4.1.8",
|
||||
"remeda": "2.26.0",
|
||||
"resolve.exports": "2.0.3",
|
||||
"sst": "4.13.1",
|
||||
"shiki": "4.4.3",
|
||||
"solid-list": "0.3.0",
|
||||
|
||||
+1
-102
@@ -241,115 +241,14 @@ Constructing `stream()` or `generate()` does not record a request, invoke a resp
|
||||
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
|
||||
future reply.
|
||||
|
||||
Generation responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
|
||||
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
|
||||
it does not repair or truncate them.
|
||||
|
||||
For explicit compaction, script a `CompactionResponse` through `push`, `always`, or `serve`. Its `replacement` contains the next context window, including retained user messages. The client returns that result and usage directly, with the same lazy request recording and gates. Generation and compaction reject fixtures for the wrong operation instead of converting between response shapes.
|
||||
|
||||
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
|
||||
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
|
||||
`testLayer`.
|
||||
|
||||
## Provider compaction
|
||||
|
||||
Compaction is opt-in. The package supports automatic compaction in OpenAI/Azure Responses and Anthropic Messages (including Claude on Vertex), and explicit compaction calls in OpenAI/Azure/xAI Responses. Model and deployment support still depends on the provider. Bedrock compaction is deferred to a separate follow-up.
|
||||
|
||||
This is different from prompt caching, server-side history storage, or truncation. Compaction returns provider-owned context that must be replayed to continue the conversation.
|
||||
|
||||
### Explicit compaction
|
||||
|
||||
`LLMClient.compact(request)` is the caller-controlled operation for OpenAI, Azure, and xAI Responses. It performs exactly one HTTP call to `/responses/compact`, using the selected route's endpoint, credentials, query, and HTTP middleware. It returns a `CompactionResponse` with `replacement: Message[]` and optional `usage`, not a normal generation response.
|
||||
|
||||
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
|
||||
|
||||
```ts
|
||||
const result = yield * LLMClient.compact(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: result.replacement,
|
||||
})
|
||||
const response = yield * LLMClient.generate(next)
|
||||
```
|
||||
|
||||
`replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly.
|
||||
|
||||
The selected model carries explicit-compaction capability through request construction and updates. Calls using unsupported routes fail type checking. When the model is selected dynamically, narrow the request with `LLMClient.canCompact(request)` before calling `LLMClient.compact`; a model or route switch does not inherit the old capability. Runtime validation still rejects unsupported calls from untyped consumers. Capability describes the route's API, not whether every model or custom deployment supports the operation.
|
||||
|
||||
Generation-only body overlays such as `stream` and `store` are not sent to the compact endpoint. Supported compact controls such as service tier and prompt-cache settings preserve request defaults and HTTP-overlay precedence. Retained image and file detail settings survive serialization and replay.
|
||||
|
||||
The input must still fit the model's context window. Explicit compaction is not an overflow-recovery operation. Anthropic does not expose this operation in this package; its in-band compaction remains available below. Compatible routes do not inherit an explicit compact endpoint simply because they use a Responses protocol.
|
||||
|
||||
### Advanced: in-band compaction
|
||||
|
||||
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
|
||||
|
||||
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
|
||||
|
||||
```ts
|
||||
import { LLM, LLMClient, LLMRequest, Message } from "@opencode-ai/ai"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
const request = LLM.request({
|
||||
model: OpenAI.configure({ apiKey }).responses("gpt-5.3-codex"),
|
||||
messages,
|
||||
providerOptions: {
|
||||
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
|
||||
},
|
||||
})
|
||||
const response = yield * LLMClient.generate(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: [...request.messages, response.message, Message.user("Continue")],
|
||||
})
|
||||
```
|
||||
|
||||
`store: false` remains the default. Keep the entire `response.message`, not just `response.text`. Compaction events become ordered `CompactionPart`s alongside text and reasoning. The conversation contains everything needed to continue; there is no separate replay object or hidden provider transcript.
|
||||
|
||||
A compaction part has `provider` and exactly one representation: `encrypted` for Responses, or `text` for Anthropic. Responses also preserves the optional checkpoint `id`. These fields survive message serialization without becoming visible assistant text. Sending a checkpoint to another provider or an incompatible API fails rather than silently losing context.
|
||||
|
||||
```ts
|
||||
import { CompactionPart, ProviderID } from "@opencode-ai/ai"
|
||||
|
||||
CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_123", encrypted: "..." })
|
||||
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "Summary of the conversation..." })
|
||||
```
|
||||
|
||||
For Anthropic, use:
|
||||
|
||||
```ts
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [{
|
||||
type: "compact_20260112",
|
||||
trigger: { type: "input_tokens", value: 150_000 },
|
||||
pauseAfterCompaction: true,
|
||||
instructions: "Summarize the task and decisions. Do not call tools while summarizing.",
|
||||
}],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- The trigger is optional (provider default: 150,000 tokens), with a minimum of 50,000.
|
||||
- Custom instructions replace Anthropic's default summarization instructions.
|
||||
- The route adds `compact-2026-01-12` to existing beta headers, including when replaying a checkpoint without enabling new compactions.
|
||||
- A pause is exposed as `response.finishReason.raw === "compaction"`. It occurs only if the threshold triggers compaction: `pauseAfterCompaction` does not mean "compact now". The caller explicitly issues the next request; the package never automatically resumes.
|
||||
- Anthropic can return a compaction block with `content: null` when summarization fails. This becomes a compaction part with `text: null`, which is **not** a successful replacement for prior history. The package never prunes history automatically.
|
||||
- `Usage` totals include all reported Anthropic `usage.iterations`, including compaction. `contextTokens` separately reports the final message iteration's inclusive input size, when available. A compaction-only pause does not report a post-compaction context size. Raw iteration usage remains in `providerMetadata`.
|
||||
|
||||
### Ownership and verification
|
||||
|
||||
The AI package transports options and typed conversation parts. It does not schedule compaction, persist Session checkpoints, select history, switch providers, or replace Core's existing local compaction policy. Native compaction is not enabled for OpenCode Sessions by this feature; Session integration must persist these parts before enabling it. The AI SDK bridge rejects native compaction parts rather than dropping them. Provider-executed tool APIs and persistence changes are a separate follow-up.
|
||||
|
||||
Tests cover serialized round trips, real local HTTP plus a tool loop, WebSocket recovery, provider errors, malformed blocks, and usage accounting. Live provider tests are gated by `RECORD=true` and the relevant API keys:
|
||||
|
||||
```sh
|
||||
# Run from packages/ai. Only records the selected new cassette group.
|
||||
RECORD=true RECORDED_PREFIX=openai-compaction bun test test/provider/compaction.recorded.test.ts
|
||||
RECORD=true RECORDED_PREFIX=xai-compaction bun test test/provider/compaction.recorded.test.ts
|
||||
RECORD=true RECORDED_PREFIX=anthropic-compaction bun test test/provider/compaction.recorded.test.ts
|
||||
```
|
||||
|
||||
Provider references: [OpenAI](https://developers.openai.com/api/docs/guides/compaction), [Azure](https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/responses#server-side-compaction), [Anthropic](https://platform.claude.com/docs/en/build-with-claude/compaction), [xAI](https://docs.x.ai/developers/advanced-api-usage/context-compaction).
|
||||
|
||||
## Caching
|
||||
|
||||
Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "auto"` unless the caller opts out with `cache: "none"`. Each protocol translates `CacheHint`s to its wire format (`cache_control` on Anthropic, `cachePoint` on Bedrock; OpenAI and Gemini do implicit caching server-side and don't need inline markers — auto is a no-op there).
|
||||
|
||||
@@ -6,12 +6,8 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import {
|
||||
AIError,
|
||||
HttpOptions,
|
||||
LLMRequest,
|
||||
LLMEvent,
|
||||
mergeJsonRecords,
|
||||
Usage,
|
||||
@@ -19,6 +15,7 @@ import {
|
||||
type FinishReasonDetails,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
@@ -64,8 +61,6 @@ export type ThinkingInput =
|
||||
))
|
||||
|
||||
export interface OptionsInput {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly [key: string]: unknown
|
||||
readonly thinking?: ThinkingInput
|
||||
readonly effort?: string
|
||||
@@ -94,23 +89,6 @@ export interface OptionsInput {
|
||||
|
||||
export type ProviderOptionsInput = OptionsInput
|
||||
|
||||
export const ContextManagement = Schema.Struct({
|
||||
edits: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compact_20260112"),
|
||||
trigger: Schema.optional(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("input_tokens"),
|
||||
value: Schema.Int.check(Schema.isGreaterThanOrEqualTo(50000)),
|
||||
}),
|
||||
),
|
||||
pauseAfterCompaction: Schema.optional(Schema.Boolean),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type ContextManagement = typeof ContextManagement.Type
|
||||
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
@@ -258,12 +236,7 @@ const AnthropicUserBlock = Schema.Union([
|
||||
AnthropicToolResultBlock,
|
||||
])
|
||||
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||
const AnthropicCompactionBlock = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
content: Schema.NullOr(Schema.String),
|
||||
})
|
||||
const AnthropicAssistantBlock = Schema.Union([
|
||||
AnthropicCompactionBlock,
|
||||
AnthropicTextBlock,
|
||||
AnthropicThinkingBlock,
|
||||
AnthropicRedactedThinkingBlock,
|
||||
@@ -339,18 +312,6 @@ const AnthropicContainer = Schema.Union([
|
||||
])
|
||||
|
||||
const AnthropicBodyFields = {
|
||||
context_management: Schema.optional(
|
||||
Schema.Struct({
|
||||
edits: Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compact_20260112"),
|
||||
trigger: ContextManagement.fields.edits.value.fields.trigger,
|
||||
pause_after_compaction: Schema.optional(Schema.Boolean),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
model: Schema.String,
|
||||
system: optionalArray(AnthropicTextBlock),
|
||||
messages: Schema.Array(AnthropicMessage),
|
||||
@@ -374,7 +335,7 @@ const AnthropicBodyFields = {
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicIterationUsage = Schema.StructWithRest(
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: optionalNull(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
@@ -393,13 +354,6 @@ const AnthropicIterationUsage = Schema.StructWithRest(
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
...AnthropicIterationUsage.schema.fields,
|
||||
iterations: Schema.optional(Schema.Array(AnthropicIterationUsage)),
|
||||
}),
|
||||
[JsonObject],
|
||||
)
|
||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||
|
||||
const AnthropicStreamBlock = Schema.Struct({
|
||||
@@ -423,7 +377,6 @@ type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
|
||||
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
|
||||
|
||||
const AnthropicStreamDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
type: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
thinking: Schema.optional(Schema.String),
|
||||
@@ -453,8 +406,6 @@ const AnthropicEvent = Schema.Struct({
|
||||
type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly provider: LLMRequest["model"]["provider"]
|
||||
readonly compactions: Readonly<Record<number, string | null>>
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
@@ -897,12 +848,6 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||
if (message.role === "assistant") {
|
||||
const content: AnthropicAssistantBlock[] = []
|
||||
for (const part of message.content) {
|
||||
if (part.type === "compaction") {
|
||||
if (part.provider !== request.model.provider || part.text === undefined)
|
||||
return yield* invalid("Compaction state must be replayed to its originating provider and API")
|
||||
content.push({ type: "compaction", content: part.text })
|
||||
continue
|
||||
}
|
||||
if (part.type === "text") {
|
||||
if (part.text.trim().length === 0) continue
|
||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||
@@ -1058,9 +1003,6 @@ const resolveThinking = Effect.fn("AnthropicMessages.resolveThinking")(function*
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
@@ -1095,7 +1037,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
)
|
||||
}
|
||||
const options = yield* resolveOptions(request)
|
||||
const body = {
|
||||
return {
|
||||
model: request.model.id,
|
||||
system,
|
||||
messages,
|
||||
@@ -1116,18 +1058,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
metadata: options.metadata,
|
||||
service_tier: options.service_tier,
|
||||
}
|
||||
if (!management) return body
|
||||
return {
|
||||
...body,
|
||||
context_management: {
|
||||
edits: management.edits.map((edit) => ({
|
||||
type: edit.type,
|
||||
trigger: edit.trigger,
|
||||
pause_after_compaction: edit.pauseAfterCompaction,
|
||||
instructions: edit.instructions,
|
||||
})),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
@@ -1149,31 +1079,18 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined, providerMetadataKey: string): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const iterations = usage.iterations?.length ? usage.iterations : [usage]
|
||||
const last = usage.iterations?.at(-1)
|
||||
const nonCached = ProviderShared.sumTokens(...iterations.map((item) => item.input_tokens ?? undefined))
|
||||
const cacheRead = ProviderShared.sumTokens(...iterations.map((item) => item.cache_read_input_tokens ?? undefined))
|
||||
const cacheWrite = ProviderShared.sumTokens(
|
||||
...iterations.map((item) => item.cache_creation_input_tokens ?? undefined),
|
||||
)
|
||||
const nonCached = usage.input_tokens ?? undefined
|
||||
const cacheRead = usage.cache_read_input_tokens ?? undefined
|
||||
const cacheWrite = usage.cache_creation_input_tokens ?? undefined
|
||||
const inputTokens = ProviderShared.sumTokens(nonCached, cacheRead, cacheWrite)
|
||||
const outputTokens = ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens))
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
contextTokens:
|
||||
last?.type === "message"
|
||||
? ProviderShared.sumTokens(
|
||||
last.input_tokens ?? undefined,
|
||||
last.cache_read_input_tokens ?? undefined,
|
||||
last.cache_creation_input_tokens ?? undefined,
|
||||
)
|
||||
: undefined,
|
||||
outputTokens: usage.output_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: ProviderShared.sumTokens(...iterations.map((item) => item.output_tokens_details?.thinking_tokens)),
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
})
|
||||
}
|
||||
@@ -1195,7 +1112,6 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined, providerM
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
contextTokens: right.contextTokens ?? left.contextTokens,
|
||||
nonCachedInputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheWriteInputTokens,
|
||||
@@ -1254,6 +1170,7 @@ const onContentBlockStart = (
|
||||
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
|
||||
): StepResult => {
|
||||
const block = event.content_block
|
||||
if (!block) return [state, NO_EVENTS]
|
||||
|
||||
if (block.type === "tool_use" || block.type === "server_tool_use") {
|
||||
if (event.index === undefined || !block.id) return [state, NO_EVENTS]
|
||||
@@ -1348,16 +1265,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
) {
|
||||
const delta = event.delta
|
||||
|
||||
if (delta.type === "compaction_delta") {
|
||||
if (event.index === undefined || !(event.index in state.compactions) || delta.content === undefined)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "Compaction delta is missing its block or content")
|
||||
return [
|
||||
{ ...state, compactions: { ...state.compactions, [event.index]: delta.content } },
|
||||
NO_EVENTS,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta.type === "text_delta" && delta.text) {
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -1366,7 +1274,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta.type === "thinking_delta" && delta.thinking) {
|
||||
if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -1378,7 +1286,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta.type === "signature_delta" && delta.signature) {
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
const index = event.index ?? 0
|
||||
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
|
||||
return [
|
||||
@@ -1390,7 +1298,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta.type === "input_json_delta" && event.index !== undefined) {
|
||||
if (delta?.type === "input_json_delta" && event.index !== undefined) {
|
||||
if (!delta.partial_json) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (!state.tools[event.index]) return [state, NO_EVENTS] satisfies StepResult
|
||||
const result = ToolStream.appendExisting(
|
||||
@@ -1415,18 +1323,6 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
event: AnthropicEvent,
|
||||
) {
|
||||
if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult
|
||||
if (event.index in state.compactions) {
|
||||
const { [event.index]: content, ...compactions } = state.compactions
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.compaction({
|
||||
provider: state.provider,
|
||||
text: content,
|
||||
}),
|
||||
)
|
||||
return [{ ...state, compactions, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
@@ -1478,8 +1374,6 @@ const onMessageDelta = (
|
||||
}
|
||||
|
||||
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
|
||||
if (Object.keys(state.compactions).length)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "Response ended with an incomplete compaction block")
|
||||
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
@@ -1524,21 +1418,16 @@ const onError = (event: AnthropicEvent) => {
|
||||
)
|
||||
}
|
||||
|
||||
const STREAM_BLOCK_TYPES = new Set([
|
||||
"compaction",
|
||||
"text",
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
"tool_use",
|
||||
"server_tool_use",
|
||||
])
|
||||
const STREAM_DELTA_TYPES = new Set([
|
||||
"compaction_delta",
|
||||
"text_delta",
|
||||
"thinking_delta",
|
||||
"signature_delta",
|
||||
"input_json_delta",
|
||||
])
|
||||
const isKnownStreamBlockType = (type: string) =>
|
||||
type === "text" ||
|
||||
type === "thinking" ||
|
||||
type === "redacted_thinking" ||
|
||||
type === "tool_use" ||
|
||||
type === "server_tool_use" ||
|
||||
isServerToolResultType(type)
|
||||
|
||||
const isKnownStreamDeltaType = (type: string) =>
|
||||
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
|
||||
|
||||
const invalidStreamEvent = (event: AnthropicEvent) =>
|
||||
Effect.fail(
|
||||
@@ -1567,16 +1456,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_start") {
|
||||
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
|
||||
return invalidStreamEvent(event)
|
||||
if (event.content_block.type === "compaction") {
|
||||
const decoded = Schema.decodeUnknownOption(AnthropicCompactionBlock)(event.content_block)
|
||||
if (event.index === undefined || Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
return Effect.succeed<StepResult>([
|
||||
{ ...state, compactions: { ...state.compactions, [event.index]: decoded.value.content } },
|
||||
NO_EVENTS,
|
||||
])
|
||||
}
|
||||
if (!STREAM_BLOCK_TYPES.has(event.content_block.type) && !isServerToolResultType(event.content_block.type))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
const decoded = decodeAnthropicStreamBlock(event.content_block)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
const block = decoded.value
|
||||
@@ -1590,7 +1470,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
}
|
||||
if (event.type === "content_block_delta") {
|
||||
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
|
||||
if (typeof event.delta.type === "string" && !STREAM_DELTA_TYPES.has(event.delta.type))
|
||||
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
const decoded = decodeAnthropicStreamDelta(event.delta)
|
||||
if (Option.isNone(decoded)) return invalidStreamEvent(event)
|
||||
@@ -1624,8 +1504,6 @@ export const protocol = Protocol.make({
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: (request) => ({
|
||||
provider: request.model.provider,
|
||||
compactions: {},
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
tools: ToolStream.empty<number>(),
|
||||
reasoningSignatures: {},
|
||||
@@ -1635,37 +1513,6 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
})
|
||||
|
||||
export const transport = <Body extends Pick<AnthropicMessagesBody, "messages" | "context_management">>() => {
|
||||
const http = HttpTransport.httpJson<Body, string>({ framing })
|
||||
return {
|
||||
...http,
|
||||
prepare: (input: Parameters<typeof http.prepare>[0]) => {
|
||||
if (
|
||||
!input.body.context_management?.edits.length &&
|
||||
!input.body.messages.some((message) => message.content.some((block) => block.type === "compaction"))
|
||||
)
|
||||
return http.prepare(input)
|
||||
const headers = Headers.fromInput(input.request.http?.headers)
|
||||
const betas = new Set(
|
||||
(headers["anthropic-beta"] ?? "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
betas.add("compact-2026-01-12")
|
||||
return http.prepare({
|
||||
...input,
|
||||
request: LLMRequest.update(input.request, {
|
||||
http: new HttpOptions({
|
||||
...input.request.http,
|
||||
headers: { ...headers, "anthropic-beta": [...betas].join(",") },
|
||||
}),
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
provider: "anthropic",
|
||||
@@ -1675,7 +1522,7 @@ export const route = Route.make({
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
}),
|
||||
auth: Auth.none,
|
||||
transport: transport<AnthropicMessagesBody>(),
|
||||
framing,
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -32,19 +32,17 @@ export const PATH = "/responses"
|
||||
// =============================================================================
|
||||
// Request Body Schema
|
||||
// =============================================================================
|
||||
export const OpenResponsesInputText = Schema.Struct({
|
||||
const OpenResponsesInputText = Schema.Struct({
|
||||
type: Schema.tag("input_text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
export const OpenResponsesInputImage = Schema.Struct({
|
||||
const OpenResponsesInputImage = Schema.Struct({
|
||||
type: Schema.tag("input_image"),
|
||||
image_url: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
})
|
||||
export const OpenResponsesInputFile = Schema.Struct({
|
||||
const OpenResponsesInputFile = Schema.Struct({
|
||||
type: Schema.tag("input_file"),
|
||||
filename: Schema.String,
|
||||
detail: Schema.optional(Schema.String),
|
||||
file_data: Schema.optional(Schema.String),
|
||||
file_url: Schema.optional(Schema.String),
|
||||
})
|
||||
@@ -56,7 +54,7 @@ const MediaInput = Schema.Union([OpenResponsesInputImage, OpenResponsesInputFile
|
||||
export type MediaInput = Schema.Schema.Type<typeof MediaInput>
|
||||
const OpenResponsesInputContent = Schema.Union([OpenResponsesInputText, MediaInput])
|
||||
|
||||
export const OpenResponsesOutputText = Schema.Struct({
|
||||
const OpenResponsesOutputText = Schema.Struct({
|
||||
type: Schema.tag("output_text"),
|
||||
text: Schema.String,
|
||||
})
|
||||
@@ -64,13 +62,6 @@ export const OpenResponsesOutputText = Schema.Struct({
|
||||
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
export const MessageMetadata = Schema.Struct({
|
||||
itemId: Schema.optional(Schema.String),
|
||||
type: Schema.optional(Schema.Literal("message")),
|
||||
status: Schema.optional(Schema.String),
|
||||
phase: Schema.optional(MessagePhase),
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
@@ -81,7 +72,7 @@ const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const OpenResponsesReasoningItem = Schema.Struct({
|
||||
const OpenResponsesReasoningItem = Schema.Struct({
|
||||
type: Schema.tag("reasoning"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
summary: Schema.Array(OpenResponsesReasoningSummaryText),
|
||||
@@ -158,30 +149,16 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
||||
])
|
||||
|
||||
export const CompactionItem = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
id: optionalNull(Schema.String),
|
||||
encrypted_content: Schema.String,
|
||||
})
|
||||
|
||||
export const InputItem = Schema.Union([
|
||||
CompactionItem,
|
||||
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),
|
||||
type: Schema.optional(Schema.Literal("message")),
|
||||
id: Schema.optional(Schema.String),
|
||||
status: Schema.optional(Schema.String),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("message"),
|
||||
id: Schema.optionalKey(Schema.String),
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
status: Schema.optional(Schema.String),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
Schema.Struct({
|
||||
@@ -290,7 +267,7 @@ const OpenResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
|
||||
|
||||
export const OpenResponsesUsage = Schema.Struct({
|
||||
const OpenResponsesUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
@@ -410,8 +387,6 @@ export interface ProviderAdapter {
|
||||
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
|
||||
|
||||
export interface ParserState {
|
||||
readonly provider: LLMRequest["model"]["provider"]
|
||||
readonly completedCompactions: ReadonlySet<string>
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly providerMetadataKey: string
|
||||
@@ -516,9 +491,6 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const providerMedia = adapter.lowerMedia?.({ part, media, request })
|
||||
if (providerMedia) return providerMedia
|
||||
const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(
|
||||
part.providerMetadata?.[metadataKey(request.model)]?.detail,
|
||||
)
|
||||
const url =
|
||||
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
|
||||
? part.data
|
||||
@@ -529,15 +501,10 @@ const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
return {
|
||||
type: "input_file" as const,
|
||||
filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
|
||||
detail,
|
||||
...(url ? { file_url: url } : { file_data: media.dataUrl }),
|
||||
}
|
||||
}
|
||||
return {
|
||||
type: "input_image" as const,
|
||||
image_url: url ?? media.dataUrl,
|
||||
detail,
|
||||
}
|
||||
return { type: "input_image" as const, image_url: url ?? media.dataUrl }
|
||||
})
|
||||
|
||||
const lowerUserContent = Effect.fnUntraced(function* (
|
||||
@@ -604,12 +571,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const input: LoweredInputItem[] = []
|
||||
const providerMetadataKey = metadataKey(request.model)
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
for (const message of request.messages) {
|
||||
const metadata = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(MessageMetadata)),
|
||||
)(message.providerMetadata?.[providerMetadataKey])
|
||||
if (message.role === "system") {
|
||||
input.push({
|
||||
role: "developer",
|
||||
@@ -620,8 +584,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
|
||||
if (message.role === "user") {
|
||||
const content = yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request, adapter))
|
||||
if (content.length > 0)
|
||||
input.push({ role: "user", content, type: metadata?.type, id: metadata?.itemId, status: metadata?.status })
|
||||
if (content.length > 0) input.push({ role: "user", content })
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -634,10 +597,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
const groups = content.reduce<
|
||||
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
|
||||
>((groups, part) => {
|
||||
const partMetadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey) ?? metadata?.itemId
|
||||
const partPhase = messagePhase(partMetadata?.phase)
|
||||
const phase = partPhase === undefined ? metadata?.phase : partPhase
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const phase = ProviderShared.isRecord(metadata) ? messagePhase(metadata.phase) : undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.id === id && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ id, phase, parts: [part] })
|
||||
@@ -648,7 +610,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
type: "message" as const,
|
||||
...(group.id === undefined ? {} : { id: group.id }),
|
||||
role: "assistant" as const,
|
||||
status: metadata?.status,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
@@ -656,15 +617,6 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
content.splice(0, content.length)
|
||||
}
|
||||
for (const part of message.content) {
|
||||
if (part.type === "compaction") {
|
||||
flushText()
|
||||
if (part.provider !== request.model.provider || part.encrypted === undefined)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Compaction state must be replayed to its originating provider and API",
|
||||
)
|
||||
input.push({ type: "compaction", id: part.id, encrypted_content: part.encrypted })
|
||||
continue
|
||||
}
|
||||
if (part.type === "text") {
|
||||
content.push(part)
|
||||
continue
|
||||
@@ -741,30 +693,13 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
return input
|
||||
})
|
||||
|
||||
export const lowerConversation = Effect.fn("OpenResponses.lowerConversation")(function* (
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const instructions = ProviderShared.joinText(request.system)
|
||||
return {
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request, adapter),
|
||||
...(instructions ? { instructions } : {}),
|
||||
}
|
||||
})
|
||||
|
||||
export const lowerGeneration = (request: LLMRequest) => {
|
||||
const lowerOptions = (request: LLMRequest) => {
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const generation = request.generation
|
||||
const instructions = ProviderShared.joinText(request.system)
|
||||
const cacheKey = ProviderShared.promptCacheKey(request)
|
||||
const parallelToolCalls = resolveParallelToolCalls(request)
|
||||
return {
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
...(instructions ? { instructions } : {}),
|
||||
...(options.store !== undefined ? { store: options.store } : {}),
|
||||
...(options.metadata ? { metadata: options.metadata } : {}),
|
||||
...(options.safetyIdentifier ? { safety_identifier: options.safetyIdentifier } : {}),
|
||||
@@ -792,7 +727,7 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
|
||||
return disabled === undefined ? undefined : !disabled
|
||||
}
|
||||
|
||||
export const allowedToolChoice = (request: LLMRequest) => {
|
||||
const allowedToolChoice = (request: LLMRequest) => {
|
||||
const allowed = OpenResponsesOptions.resolve(request).allowedTools
|
||||
if (!allowed) return undefined
|
||||
return {
|
||||
@@ -806,10 +741,11 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
...(yield* lowerConversation(request, adapter)),
|
||||
...lowerGeneration(request),
|
||||
model: request.model.id,
|
||||
input: yield* lowerMessages(request, adapter),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -823,6 +759,13 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
presence_penalty: generation?.presencePenalty,
|
||||
frequency_penalty: generation?.frequencyPenalty,
|
||||
...lowerOptions(request),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -839,7 +782,7 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (req
|
||||
// cached-read and cache-write subsets, and `output_tokens` (inclusive total)
|
||||
// with a `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// non-cached breakdown.
|
||||
export const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||
const mapUsage = (usage: OpenResponsesUsage | null | undefined, providerMetadataKey: string) => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.input_tokens_details?.cached_tokens
|
||||
const cacheWrite = usage.input_tokens_details?.cache_write_tokens
|
||||
@@ -869,8 +812,6 @@ const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason =
|
||||
return hasFunctionCall ? "tool-calls" : "unknown"
|
||||
}
|
||||
|
||||
export const metadataKey = (model: LLMRequest["model"]) => model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
export const providerMetadata = (state: ParserState, metadata: Record<string, unknown>): ProviderMetadata => ({
|
||||
[state.providerMetadataKey]: metadata,
|
||||
})
|
||||
@@ -1152,25 +1093,6 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
) {
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "compaction") {
|
||||
if (!item.id || typeof item.encrypted_content !== "string")
|
||||
return yield* ProviderShared.eventError(state.id, "Compaction output is missing its id or encrypted content")
|
||||
if (state.completedCompactions.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(
|
||||
LLMEvent.compaction({
|
||||
provider: state.provider,
|
||||
id: item.id,
|
||||
encrypted: item.encrypted_content,
|
||||
}),
|
||||
)
|
||||
return [
|
||||
{ ...state, lifecycle, completedCompactions: new Set([...state.completedCompactions, item.id]) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (item.type === "message" && item.id !== undefined) {
|
||||
if (state.completedMessages.has(item.id)) return [state, NO_EVENTS] satisfies StepResult
|
||||
const completedMessages = new Set(state.completedMessages)
|
||||
@@ -1335,38 +1257,29 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
const events: LLMEvent[] = []
|
||||
if (event.type === "response.completed") {
|
||||
for (const item of event.response?.output ?? []) {
|
||||
if (item.type !== "compaction" && item.type !== "function_call") continue
|
||||
if (item.type === "compaction") {
|
||||
// Terminal recovery cannot insert a checkpoint before already-emitted content.
|
||||
if (state.lifecycle.stepStarted && !state.completedCompactions.has(item.id ?? ""))
|
||||
return yield* ProviderShared.eventError(
|
||||
state.id,
|
||||
"Cannot recover a compaction checkpoint after output has been emitted",
|
||||
)
|
||||
}
|
||||
if (
|
||||
item.type === "function_call" &&
|
||||
(!item.call_id || !Object.values(current.tools).some((tool) => tool?.id === item.call_id))
|
||||
item.type !== "function_call" ||
|
||||
!item.call_id ||
|
||||
!Object.values(current.tools).some((tool) => tool?.id === item.call_id)
|
||||
)
|
||||
continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
events.push(...emitted)
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending = yield* ToolStream.finishAll(current.id, current.tools)
|
||||
current = {
|
||||
...current,
|
||||
tools: pending.tools,
|
||||
hasFunctionCall:
|
||||
current.hasFunctionCall ||
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)),
|
||||
}
|
||||
events.push(...pending.events)
|
||||
}
|
||||
// Some compatible providers omit output_item.done even after completing the response.
|
||||
const pending =
|
||||
event.type === "response.completed"
|
||||
? yield* ToolStream.finishAll(current.id, current.tools)
|
||||
: { tools: current.tools, events: NO_EVENTS }
|
||||
events.push(...pending.events)
|
||||
const hasFunctionCall =
|
||||
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
current.hasFunctionCall
|
||||
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event, current.hasFunctionCall),
|
||||
normalized: mapFinishReason(event, hasFunctionCall),
|
||||
raw: event.response?.incomplete_details?.reason,
|
||||
},
|
||||
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
|
||||
@@ -1378,7 +1291,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...current, lifecycle }, events] satisfies StepResult
|
||||
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
@@ -1513,11 +1426,9 @@ export const step = (state: ParserState, input: Event) => {
|
||||
* implementations compose this baseline with their own tools and event variants.
|
||||
*/
|
||||
export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER): ParserState => ({
|
||||
provider: request.model.provider,
|
||||
completedCompactions: new Set<string>(),
|
||||
id: adapter.id,
|
||||
name: adapter.name,
|
||||
providerMetadataKey: metadataKey(request.model),
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? "openresponses",
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
completedTools: new Set<string>(),
|
||||
|
||||
@@ -5,14 +5,13 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import type { LLMRequest, JsonSchema, ToolDefinition } from "../schema/index.js"
|
||||
import { LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
@@ -21,14 +20,6 @@ const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = OpenResponses.PATH
|
||||
|
||||
export const ContextManagement = Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
compactThreshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
}),
|
||||
)
|
||||
export type ContextManagement = typeof ContextManagement.Type
|
||||
|
||||
const OpenAIResponsesImageGenerationTool = Schema.Struct({
|
||||
type: Schema.tag("image_generation"),
|
||||
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
|
||||
@@ -87,14 +78,6 @@ const OpenAIResponsesCoreFields = {
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
context_management: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
compact_threshold: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))),
|
||||
}),
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
const OpenAIResponsesBody = Schema.Struct({
|
||||
@@ -142,14 +125,15 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const body = yield* OpenResponses.fromRequestWithAdapter(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
adapter,
|
||||
)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const parallelToolCalls = OpenResponses.resolveParallelToolCalls(request)
|
||||
return yield* decodeBody({
|
||||
...(yield* OpenResponses.lowerConversation(request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request),
|
||||
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
|
||||
...body,
|
||||
...(parallelToolCalls === undefined ? {} : { parallel_tool_calls: parallelToolCalls }),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -157,8 +141,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tool_choice:
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
body.tool_choice ?? (request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -240,7 +223,6 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: ResponsesCompaction.make(adapter),
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
AIError,
|
||||
InvalidProviderOutputError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
HttpOptions,
|
||||
LLMRequest,
|
||||
Message,
|
||||
type ContentPart,
|
||||
mergeJsonRecords,
|
||||
} from "../../schema/index.js"
|
||||
import type { CompactOperation } from "../../route/client.js"
|
||||
import { Endpoint } from "../../route/endpoint.js"
|
||||
import { RequestExecutor } from "../../route/executor.js"
|
||||
import { HttpTransport } from "../../route/transport/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
|
||||
|
||||
const Body = Schema.Struct({
|
||||
model: Schema.String,
|
||||
input: Schema.Array(Schema.Unknown),
|
||||
instructions: optionalNull(Schema.String),
|
||||
previous_response_id: optionalNull(Schema.String),
|
||||
service_tier: optionalNull(Schema.String),
|
||||
prompt_cache_key: optionalNull(Schema.String),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
const Text = Schema.Union([OpenResponses.OpenResponsesInputText, OpenResponses.OpenResponsesOutputText])
|
||||
const File = Schema.Union([
|
||||
Schema.Struct({
|
||||
...OpenResponses.OpenResponsesInputFile.fields,
|
||||
file_url: Schema.String,
|
||||
file_data: Schema.optional(Schema.Never),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...OpenResponses.OpenResponsesInputFile.fields,
|
||||
file_data: Schema.String,
|
||||
file_url: Schema.optional(Schema.Never),
|
||||
}),
|
||||
])
|
||||
const MessageFields = {
|
||||
type: Schema.Literal("message"),
|
||||
id: Schema.optional(Schema.String),
|
||||
status: Schema.optional(Schema.String),
|
||||
phase: Schema.optional(OpenResponses.MessagePhase),
|
||||
}
|
||||
const Response = Schema.Struct({
|
||||
object: Schema.Literal("response.compaction"),
|
||||
output: Schema.Array(
|
||||
Schema.Union([
|
||||
OpenResponses.CompactionItem,
|
||||
OpenResponses.OpenResponsesReasoningItem,
|
||||
Schema.Struct({
|
||||
...MessageFields,
|
||||
role: Schema.Literal("user"),
|
||||
content: Schema.Array(Schema.Union([Text, OpenResponses.OpenResponsesInputImage, File])).check(
|
||||
Schema.isMinLength(1),
|
||||
),
|
||||
}),
|
||||
Schema.Struct({
|
||||
...MessageFields,
|
||||
role: Schema.Literal("assistant"),
|
||||
content: Schema.Array(Text).check(Schema.isMinLength(1)),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
usage: Schema.optional(Schema.StructWithRest(OpenResponses.OpenResponsesUsage, [JsonObject])),
|
||||
})
|
||||
|
||||
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
|
||||
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
|
||||
const route = request.model.route
|
||||
const native = yield* OpenResponses.lowerConversation(request, adapter)
|
||||
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
...native,
|
||||
service_tier: request.providerOptions?.serviceTier,
|
||||
prompt_cache_key: ProviderShared.promptCacheKey(request),
|
||||
},
|
||||
request.http?.body,
|
||||
),
|
||||
)
|
||||
const url = Endpoint.render(route.endpoint, { request, body: native })
|
||||
url.pathname = `${url.pathname.replace(/\/$/, "")}/compact`
|
||||
const parts = yield* HttpTransport.jsonRequestParts({
|
||||
request: LLMRequest.update(request, {
|
||||
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
|
||||
}),
|
||||
body,
|
||||
endpoint: Endpoint.path(url.toString()),
|
||||
auth: route.auth,
|
||||
encodeBody: Schema.encodeSync(Schema.fromJsonString(Body)),
|
||||
})
|
||||
const response = yield* executor.execute(
|
||||
ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
options?.http,
|
||||
)
|
||||
const text = yield* RequestExecutor.responseStream(response).pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.runFold(
|
||||
() => "",
|
||||
(text, chunk) => text + chunk,
|
||||
),
|
||||
)
|
||||
const invalid = (message: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({
|
||||
route: route.id,
|
||||
message,
|
||||
body: text,
|
||||
cause,
|
||||
http: RequestExecutor.responseHttp(response),
|
||||
}),
|
||||
})
|
||||
const result = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(text).pipe(
|
||||
Effect.mapError((cause) => invalid("Invalid compaction response", cause)),
|
||||
)
|
||||
if (!result.output.some((item) => item.type === "compaction"))
|
||||
return yield* invalid("Compaction response did not contain a checkpoint")
|
||||
return new CompactionResponse({
|
||||
replacement: result.output.map((item) => toMessage(item, request.model)),
|
||||
usage: OpenResponses.mapUsage(result.usage, OpenResponses.metadataKey(request.model)),
|
||||
})
|
||||
})
|
||||
|
||||
function toMessage(item: (typeof Response.Type.output)[number], model: LLMRequest["model"]): Message {
|
||||
if (item.type === "compaction")
|
||||
return Message.assistant(
|
||||
CompactionPart.make({ provider: model.provider, id: item.id ?? undefined, encrypted: item.encrypted_content }),
|
||||
)
|
||||
|
||||
const key = OpenResponses.metadataKey(model)
|
||||
if (item.type === "reasoning") {
|
||||
const summary = item.summary.length ? item.summary : [{ text: "" }]
|
||||
return Message.assistant(
|
||||
summary.map((part) => ({
|
||||
type: "reasoning" as const,
|
||||
text: part.text,
|
||||
providerMetadata: { [key]: { itemId: item.id, reasoningEncryptedContent: item.encrypted_content } },
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
return Message.make({
|
||||
role: item.role,
|
||||
providerMetadata: { [key]: { itemId: item.id, type: item.type, status: item.status, phase: item.phase } },
|
||||
content: item.content.map((part): ContentPart => {
|
||||
if (part.type === "input_text" || part.type === "output_text") return { type: "text", text: part.text }
|
||||
if (part.type === "input_image")
|
||||
return {
|
||||
type: "media",
|
||||
data: part.image_url,
|
||||
mediaType: /^data:([^;,]+)/.exec(part.image_url)?.[1] ?? "image/*",
|
||||
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
|
||||
}
|
||||
const data = part.file_url === undefined ? part.file_data : part.file_url
|
||||
return {
|
||||
type: "media",
|
||||
data,
|
||||
filename: part.filename,
|
||||
mediaType: /^data:([^;,]+)/.exec(data)?.[1] ?? "application/octet-stream",
|
||||
providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
|
||||
}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
export * as ResponsesCompaction from "./responses-compaction.js"
|
||||
@@ -4,7 +4,6 @@ import type { LLMRequest } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
|
||||
const ADAPTER = "xai-responses"
|
||||
const NAME = "xAI Responses"
|
||||
@@ -45,10 +44,6 @@ const adapter = {
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(XAIResponsesBody))
|
||||
const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
if (request.providerOptions?.contextManagement !== undefined)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
|
||||
)
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
|
||||
})
|
||||
|
||||
@@ -89,6 +84,4 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
})
|
||||
|
||||
export const compact = ResponsesCompaction.make(adapter)
|
||||
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactOperation } from "./route/client.js"
|
||||
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
@@ -10,9 +9,8 @@ export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
export interface Definition<
|
||||
ProviderSettings extends Settings = Settings,
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options, Compact>
|
||||
readonly model: (modelID: string, settings: ProviderSettings) => LanguageModel<Options>
|
||||
}
|
||||
|
||||
export * as ProviderPackage from "./provider-package.js"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { Route as RouteDef, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -102,11 +102,7 @@ const auth = (input: Config) => {
|
||||
)
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
modelID: string | ModelID,
|
||||
) =>
|
||||
const configuredRoute = <Body, Prepared>(route: RouteDef<Body, Prepared>, input: Config, modelID: string | ModelID) =>
|
||||
route.with({
|
||||
auth: auth(input),
|
||||
endpoint: endpoint(input, modelID),
|
||||
@@ -165,11 +161,10 @@ const config = (settings: Settings): Config => {
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
Settings,
|
||||
OpenAIProviderOptionsInput,
|
||||
CompactOperation
|
||||
>["model"] = (modelID, settings) => configure(config(settings)).responses(modelID)
|
||||
export const responsesModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => configure(config(settings)).responses(modelID)
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
|
||||
@@ -57,9 +57,7 @@ const route = Route.make({
|
||||
}),
|
||||
endpoint: Endpoint.path(({ request }) => `/${request.model.id}:streamRawPredict`),
|
||||
auth: Auth.none,
|
||||
transport: AnthropicMessages.transport<
|
||||
Omit<AnthropicMessages.AnthropicMessagesBody, "model"> & { readonly anthropic_version: typeof VERSION }
|
||||
>(),
|
||||
framing: AnthropicMessages.framing,
|
||||
headers: () => ({ "anthropic-version": HEADER_VERSION }),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { mergeProviderOptions, type ProviderOptions } from "../schema/index.js"
|
||||
import type { OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
import type { Options } from "../protocols/utils/open-responses-options.js"
|
||||
import type { ContextManagement } from "../protocols/openai-responses.js"
|
||||
|
||||
export type { OpenAIResponseIncludable, OpenAIServiceTier } from "../protocols/utils/openai-options.js"
|
||||
|
||||
export type OpenAIOptionsInput = Omit<Options, "serviceTier"> & {
|
||||
/** Advanced in-band compaction. The caller owns checkpoint persistence and recovery. */
|
||||
readonly contextManagement?: ContextManagement
|
||||
readonly serviceTier?: OpenAIServiceTier
|
||||
readonly [key: string]: unknown
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactOperation } from "../route/client.js"
|
||||
import type { Route, RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
@@ -73,10 +73,7 @@ const defaults = (input: Config) => {
|
||||
return rest
|
||||
}
|
||||
|
||||
const configuredRoute = <Body, Prepared, Compact extends CompactOperation | undefined>(
|
||||
route: Route<Body, Prepared, Compact>,
|
||||
input: Config,
|
||||
) =>
|
||||
const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Config) =>
|
||||
route.with({
|
||||
auth: auth(input),
|
||||
endpoint: { baseURL: input.baseURL, query: input.queryParams },
|
||||
@@ -132,10 +129,7 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
return configure(config(settings)).responses(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput, type CompactOperation } from "../route/client.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
|
||||
@@ -13,7 +13,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
|
||||
export const id = ProviderID.make("xai")
|
||||
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput & { readonly contextManagement?: never }
|
||||
export type XAIProviderOptionsInput = OpenAIOptionsInput
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
@@ -32,7 +32,6 @@ export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
compact: XAIResponses.compact,
|
||||
id: "openai-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "xai",
|
||||
@@ -103,10 +102,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput, CompactOperation>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
export const model: ProviderPackage.Definition<Settings, XAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
|
||||
@@ -13,7 +13,6 @@ import * as ProviderShared from "../protocols/shared.js"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
AIErrorReason,
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -35,12 +34,7 @@ export interface RouteBody<Body> {
|
||||
readonly from: (request: LLMRequest) => Effect.Effect<Body, AIError>
|
||||
}
|
||||
|
||||
export interface Route<
|
||||
Body,
|
||||
Prepared = unknown,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
readonly compact: Compact
|
||||
export interface Route<Body, Prepared = unknown> {
|
||||
readonly id: string
|
||||
readonly provider?: ProviderID
|
||||
/** ProviderMetadata namespace emitted and consumed by this route. */
|
||||
@@ -48,15 +42,13 @@ export interface Route<
|
||||
readonly protocol: ProtocolID
|
||||
readonly endpoint: Endpoint.Definition<Body>
|
||||
readonly auth: Auth.Definition
|
||||
/** Deployment headers resolved once for every operation, before transport authentication. */
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly transport: Transport<Body, Prepared, unknown>
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared, Compact>
|
||||
readonly with: (patch: RoutePatch<Body, Prepared>) => Route<Body, Prepared>
|
||||
readonly model: <Options extends ProviderOptions = ProviderOptions>(
|
||||
input: RouteMappedLanguageModelInput,
|
||||
) => LanguageModel<Options, Compact>
|
||||
) => LanguageModel<Options>
|
||||
readonly prepareTransport: (
|
||||
body: Body,
|
||||
request: LLMRequest,
|
||||
@@ -74,11 +66,7 @@ export interface Route<
|
||||
// Normal call sites use `OpenAIChat.route`; callers only need body types
|
||||
// when preparing a request with a protocol-specific type assertion.
|
||||
// oxlint-disable-next-line typescript-eslint/no-explicit-any
|
||||
export type AnyRoute<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Route<
|
||||
any,
|
||||
any,
|
||||
Compact
|
||||
>
|
||||
export type AnyRoute = Route<any, any>
|
||||
|
||||
export type HttpOptionsInput = HttpOptions.Input
|
||||
|
||||
@@ -111,15 +99,15 @@ export interface RoutePatch<Body, Prepared> extends RouteDefaultsInput {
|
||||
|
||||
type RouteMappedLanguageModelInput = RouteLanguageModelInput | RouteRoutedLanguageModelInput
|
||||
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
route: AnyRoute<Compact>,
|
||||
const makeRouteLanguageModel = <Options extends ProviderOptions = ProviderOptions>(
|
||||
route: AnyRoute,
|
||||
mapped: RouteMappedLanguageModelInput,
|
||||
) => {
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
return LanguageModel.make<Options, Compact>({
|
||||
return LanguageModel.make<Options>({
|
||||
...mapped,
|
||||
provider,
|
||||
route,
|
||||
@@ -162,10 +150,6 @@ export const httpOptions = (input: HttpOptionsInput | undefined) => {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly compact: (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
readonly stream: StreamMethod
|
||||
readonly generate: GenerateMethod
|
||||
}
|
||||
@@ -183,19 +167,6 @@ export interface GenerateMethod {
|
||||
(request: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError>
|
||||
}
|
||||
|
||||
export type CompactOperation = (
|
||||
request: LLMRequest,
|
||||
executor: RequestExecutor.Interface,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
) => Effect.Effect<CompactionResponse, AIError>
|
||||
|
||||
export type CompactionRequest = LLMRequest & {
|
||||
readonly model: LanguageModel<ProviderOptions, CompactOperation>
|
||||
}
|
||||
|
||||
export const canCompact = (request: LLMRequest): request is CompactionRequest =>
|
||||
request.model.route.compact !== undefined
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
|
||||
const resolveRequestOptions = (request: LLMRequest) => {
|
||||
@@ -216,7 +187,6 @@ const resolveRequestOptions = (request: LLMRequest) => {
|
||||
}
|
||||
|
||||
export interface MakeInput<Body, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -238,7 +208,6 @@ export interface MakeInput<Body, Frame, Event, State> {
|
||||
}
|
||||
|
||||
export interface MakeTransportInput<Body, Prepared, Frame, Event, State> {
|
||||
readonly compact?: CompactOperation
|
||||
/** Route id used in diagnostics and prepared request metadata. */
|
||||
readonly id: string
|
||||
/** Provider identity for route-owned model construction. */
|
||||
@@ -314,14 +283,12 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
|
||||
const build = (routeInput: BuiltRouteInput): Route<Body, Prepared> => {
|
||||
const route: Route<Body, Prepared> = {
|
||||
compact: routeInput.compact,
|
||||
id: routeInput.id,
|
||||
provider: routeInput.provider === undefined ? undefined : ProviderID.make(routeInput.provider),
|
||||
providerMetadataKey: routeInput.providerMetadataKey,
|
||||
protocol: protocol.id,
|
||||
endpoint: routeInput.endpoint,
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
headers: routeInput.headers,
|
||||
transport: routeInput.transport,
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
@@ -343,7 +310,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
},
|
||||
model: <Options extends ProviderOptions = ProviderOptions>(input: RouteMappedLanguageModelInput) =>
|
||||
makeRouteLanguageModel<Options, CompactOperation | undefined>(route, input),
|
||||
makeRouteLanguageModel<Options>(route, input),
|
||||
prepareTransport: (body, request, options) =>
|
||||
routeInput.transport.prepare({
|
||||
body,
|
||||
@@ -351,6 +318,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
endpoint: routeInput.endpoint,
|
||||
auth: routeInput.auth ?? Auth.none,
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
middleware: options?.http,
|
||||
webSocket: options?.webSocket,
|
||||
}),
|
||||
@@ -440,12 +408,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
return build({ ...input, defaults: mergeRouteDefaults(undefined, input.defaults ?? {}) })
|
||||
}
|
||||
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, Prepared, CompactOperation>
|
||||
export function make<Body, Frame, Event, State>(
|
||||
input: MakeInput<Body, Frame, Event, State> & { readonly compact: CompactOperation },
|
||||
): Route<Body, HttpTransport.HttpPrepared<Frame>, CompactOperation>
|
||||
export function make<Body, Prepared, Frame, Event, State>(
|
||||
input: MakeTransportInput<Body, Prepared, Frame, Event, State>,
|
||||
): Route<Body, Prepared>
|
||||
@@ -473,7 +435,6 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
if ("transport" in input) return makeFromTransport(input)
|
||||
const protocol = input.protocol
|
||||
return makeFromTransport({
|
||||
compact: input.compact,
|
||||
id: input.id,
|
||||
provider: input.provider,
|
||||
providerMetadataKey: input.providerMetadataKey,
|
||||
@@ -486,19 +447,11 @@ export function make<Body, Prepared, Frame, Event, State>(
|
||||
})
|
||||
}
|
||||
|
||||
const prepareRequest = (request: LLMRequest) => {
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const original = applyCachePolicy(resolveRequestOptions(request))
|
||||
const sanitized = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
|
||||
const tools = [...new Map(sanitized.tools.map((tool) => [tool.name, tool])).values()]
|
||||
const resolved = tools.length === sanitized.tools.length ? sanitized : LLMRequest.update(sanitized, { tools })
|
||||
const headers = resolved.model.route.headers?.({ request: resolved })
|
||||
return headers === undefined
|
||||
? resolved
|
||||
: LLMRequest.update(resolved, { http: mergeHttpOptions(new HttpOptions({ headers }), resolved.http) })
|
||||
}
|
||||
|
||||
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
|
||||
const resolved = prepareRequest(request)
|
||||
const route = resolved.model.route
|
||||
|
||||
const body = yield* route.body
|
||||
@@ -557,15 +510,6 @@ export function generate(request: LLMRequest, options?: StreamOptions): Effect.E
|
||||
})
|
||||
}
|
||||
|
||||
export const compact = (
|
||||
request: CompactionRequest,
|
||||
options?: Pick<StreamOptions, "http">,
|
||||
): Effect.Effect<CompactionResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.compact(request, options)
|
||||
})
|
||||
|
||||
export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
@@ -576,29 +520,16 @@ export const streamRequest = (request: LLMRequest, options?: StreamOptions) =>
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const stream = streamRequestWith({ http: executor })
|
||||
return Service.of({
|
||||
stream,
|
||||
generate: generateWith(stream),
|
||||
compact: (request, options) =>
|
||||
Effect.suspend(() => {
|
||||
const operation = request.model.route.compact
|
||||
if (!operation)
|
||||
return ProviderShared.invalidRequest(
|
||||
`${request.model.provider}/${request.model.route.id} does not support explicit compaction`,
|
||||
)
|
||||
return operation(prepareRequest(request), executor, options)
|
||||
}),
|
||||
const stream = streamRequestWith({
|
||||
http: yield* RequestExecutor.Service,
|
||||
})
|
||||
return Service.of({ stream, generate: generateWith(stream) })
|
||||
}),
|
||||
)
|
||||
|
||||
export const Route = { make } as const
|
||||
|
||||
export const LLMClient = {
|
||||
canCompact,
|
||||
compact,
|
||||
Service,
|
||||
layer,
|
||||
stream,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { LLM } from "@opencode-ai/schema/llm"
|
||||
import { ContentBlockID, ToolCallID } from "./ids.js"
|
||||
import {
|
||||
Message,
|
||||
CompactionPart,
|
||||
ProviderMetadata,
|
||||
ToolCallPart,
|
||||
ToolOutput,
|
||||
@@ -63,8 +62,6 @@ export { ProviderMetadata } from "./messages.js"
|
||||
* Matches the same escape-hatch field on `LLMEvent`.
|
||||
*/
|
||||
export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
/** Effective input size of the final message iteration, when reported; not billed totals. */
|
||||
contextTokens: Schema.optional(Schema.Number),
|
||||
inputTokens: Schema.optional(Schema.Number),
|
||||
outputTokens: Schema.optional(Schema.Number),
|
||||
nonCachedInputTokens: Schema.optional(Schema.Number),
|
||||
@@ -75,7 +72,7 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
/**
|
||||
* Non-reasoning output tokens (including compaction summaries) — `outputTokens` minus `reasoningTokens`, clamped
|
||||
* Visible output tokens — `outputTokens` minus `reasoningTokens`, clamped
|
||||
* to zero. The one place subtraction happens in this contract; the clamp
|
||||
* means a provider reporting `reasoningTokens > outputTokens` produces a
|
||||
* harmless zero rather than a negative that crashes downstream schemas.
|
||||
@@ -91,12 +88,6 @@ export class Usage extends Schema.Class<Usage>("AI.Usage")({
|
||||
|
||||
export type UsageInput = Usage | ConstructorParameters<typeof Usage>[0]
|
||||
|
||||
/** A replacement context window, not an assistant message to append to prior history. */
|
||||
export class CompactionResponse extends Schema.Class<CompactionResponse>("LLM.CompactionResponse")({
|
||||
replacement: Schema.Array(Message),
|
||||
usage: Schema.optional(Usage),
|
||||
}) {}
|
||||
|
||||
export const StepStart = Schema.Struct({
|
||||
type: Schema.tag("step-start"),
|
||||
index: Schema.Number,
|
||||
@@ -250,7 +241,6 @@ export const ProviderErrorEvent = Schema.Struct({
|
||||
export type ProviderErrorEvent = Schema.Schema.Type<typeof ProviderErrorEvent>
|
||||
|
||||
const llmEventTagged = Schema.Union([
|
||||
CompactionPart,
|
||||
StepStart,
|
||||
TextStart,
|
||||
TextDelta,
|
||||
@@ -284,7 +274,6 @@ const toolCallID = (value: ToolCallID | string) => ToolCallID.make(value)
|
||||
* `events.filter(LLMEvent.guards["tool-call"])`.
|
||||
*/
|
||||
export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
compaction: CompactionPart.make,
|
||||
stepStart: StepStart.make,
|
||||
textStart: (input: WithID<TextStart, ContentBlockID>) => TextStart.make({ ...input, id: contentBlockID(input.id) }),
|
||||
textDelta: (input: WithID<TextDelta, ContentBlockID>) => TextDelta.make({ ...input, id: contentBlockID(input.id) }),
|
||||
@@ -322,7 +311,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
}),
|
||||
providerError: ProviderErrorEvent.make,
|
||||
is: {
|
||||
compaction: llmEventTagged.guards.compaction,
|
||||
stepStart: llmEventTagged.guards["step-start"],
|
||||
textStart: llmEventTagged.guards["text-start"],
|
||||
textDelta: llmEventTagged.guards["text-delta"],
|
||||
@@ -345,10 +333,10 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
export type LLMEvent = Schema.Schema.Type<typeof llmEventTagged>
|
||||
|
||||
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
|
||||
const joinFragments = (
|
||||
const joinFragments = <Delta extends { id: string; text: string }, End extends { id: string; text?: string }>(
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
isDelta: (event: LLMEvent) => event is LLMEvent & { id: string; text: string },
|
||||
isEnd: (event: LLMEvent) => event is LLMEvent & { id: string; text?: string },
|
||||
isDelta: (event: LLMEvent) => event is Extract<LLMEvent, Delta>,
|
||||
isEnd: (event: LLMEvent) => event is Extract<LLMEvent, End>,
|
||||
) => {
|
||||
const order: string[] = []
|
||||
const parts = new Map<string, string>()
|
||||
@@ -575,8 +563,6 @@ const reduceToolCall = (state: ResponseState, event: ToolCall): ResponseState =>
|
||||
const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseState => {
|
||||
const next = appendEvent(state, event)
|
||||
switch (event.type) {
|
||||
case "compaction":
|
||||
return appendContent(next, event)
|
||||
case "text-start":
|
||||
return ensureText(next, event.id, event.providerMetadata)
|
||||
case "text-delta":
|
||||
|
||||
@@ -7,10 +7,8 @@ import {
|
||||
HttpOptions,
|
||||
JsonSchema,
|
||||
LanguageModelSchema,
|
||||
type LanguageModel,
|
||||
ProviderOptions,
|
||||
} from "./options.js"
|
||||
import { ProviderID } from "./ids.js"
|
||||
|
||||
export const MessageRole = Schema.Literals(["system", "user", "assistant", "tool"])
|
||||
export type MessageRole = Schema.Schema.Type<typeof MessageRole>
|
||||
@@ -54,7 +52,6 @@ export const MediaPart = Schema.Struct({
|
||||
filename: Schema.optional(Schema.String),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Content.Media" })
|
||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
|
||||
@@ -188,40 +185,9 @@ export const ReasoningPart = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Content.Reasoning" })
|
||||
export type ReasoningPart = Schema.Schema.Type<typeof ReasoningPart>
|
||||
|
||||
/** A provider-generated context checkpoint, distinct from visible assistant text. */
|
||||
type CompactionContent =
|
||||
| { readonly encrypted: string; readonly text?: never }
|
||||
| { readonly text: string | null; readonly encrypted?: never }
|
||||
|
||||
const compactionPartSchema = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
provider: ProviderID,
|
||||
id: Schema.optional(Schema.String),
|
||||
encrypted: Schema.optional(Schema.String),
|
||||
/** Null means the provider failed to produce a summary; prior history must be retained. */
|
||||
text: Schema.optional(Schema.NullOr(Schema.String)),
|
||||
})
|
||||
.pipe(
|
||||
Schema.refine(
|
||||
(part): part is typeof part & CompactionContent => (part.encrypted !== undefined) !== (part.text !== undefined),
|
||||
{ message: "Compaction requires either encrypted content or a summary" },
|
||||
),
|
||||
)
|
||||
.annotate({ identifier: "LLM.Content.Compaction" })
|
||||
export type CompactionPart = typeof compactionPartSchema.Type
|
||||
export const CompactionPart = Object.assign(compactionPartSchema, {
|
||||
make: (input: Omit<CompactionPart, "type" | "encrypted" | "text"> & CompactionContent): CompactionPart =>
|
||||
Schema.decodeUnknownSync(compactionPartSchema)({ type: "compaction", ...input }),
|
||||
})
|
||||
|
||||
export const ContentPart = Schema.Union([
|
||||
TextPart,
|
||||
MediaPart,
|
||||
ToolCallPart,
|
||||
ToolResultPart,
|
||||
ReasoningPart,
|
||||
CompactionPart,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export const ContentPart = Schema.Union([TextPart, MediaPart, ToolCallPart, ToolResultPart, ReasoningPart]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type ContentPart = Schema.Schema.Type<typeof ContentPart>
|
||||
|
||||
export class Message extends Schema.Class<Message>("LLM.Message")({
|
||||
@@ -229,7 +195,6 @@ export class Message extends Schema.Class<Message>("LLM.Message")({
|
||||
role: MessageRole,
|
||||
content: Schema.Array(ContentPart),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
@@ -307,7 +272,7 @@ export namespace ToolChoice {
|
||||
}
|
||||
}
|
||||
|
||||
const requestSchema = Schema.Struct({
|
||||
export class LLMRequest extends Schema.Class<LLMRequest>("LLM.Request")({
|
||||
id: Schema.optional(Schema.String),
|
||||
model: LanguageModelSchema,
|
||||
system: Schema.Array(SystemPart),
|
||||
@@ -321,26 +286,12 @@ const requestSchema = Schema.Struct({
|
||||
// Stable cache affinity for protocols that support provider-managed prompt caching.
|
||||
promptCacheKey: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
})
|
||||
|
||||
export class LLMRequest<Model extends LanguageModel = LanguageModel> extends Schema.Class<LLMRequest>("LLM.Request")(
|
||||
requestSchema.fields,
|
||||
) {
|
||||
declare readonly model: Model
|
||||
|
||||
// Preserve model inference instead of inheriting the schema's erased constructor signature.
|
||||
// oxlint-disable-next-line no-useless-constructor
|
||||
constructor(input: LLMRequest.Input<Model>) {
|
||||
super(input)
|
||||
}
|
||||
}
|
||||
}) {}
|
||||
|
||||
export namespace LLMRequest {
|
||||
export type Input<Model extends LanguageModel = LanguageModel> = Omit<typeof requestSchema.Type, "model"> & {
|
||||
readonly model: Model
|
||||
}
|
||||
export type Input = ConstructorParameters<typeof LLMRequest>[0]
|
||||
|
||||
export const input = <Model extends LanguageModel>(request: LLMRequest<Model>): Input<Model> => ({
|
||||
export const input = (request: LLMRequest): Input => ({
|
||||
id: request.id,
|
||||
model: request.model,
|
||||
system: request.system,
|
||||
@@ -355,16 +306,7 @@ export namespace LLMRequest {
|
||||
metadata: request.metadata,
|
||||
})
|
||||
|
||||
export function update<Model extends LanguageModel>(
|
||||
request: LLMRequest,
|
||||
patch: Partial<Input<Model>> & { readonly model: Model },
|
||||
): LLMRequest<Model>
|
||||
export function update<Model extends LanguageModel>(
|
||||
request: LLMRequest<Model>,
|
||||
patch: Partial<Omit<Input, "model">> & { readonly model?: undefined },
|
||||
): LLMRequest<Model>
|
||||
export function update(request: LLMRequest, patch: Partial<Input>): LLMRequest
|
||||
export function update(request: LLMRequest, patch: Partial<Input>) {
|
||||
export const update = (request: LLMRequest, patch: Partial<Input>) => {
|
||||
if (Object.keys(patch).length === 0) return request
|
||||
return new LLMRequest({
|
||||
...input(request),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { ModelID, ProviderID } from "./ids.js"
|
||||
import type { AnyRoute, CompactOperation } from "../route/client.js"
|
||||
import type { AnyRoute } from "../route/client.js"
|
||||
import { isRecord } from "../utils/record.js"
|
||||
|
||||
export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -173,18 +173,15 @@ export namespace LanguageModelCompatibility {
|
||||
input instanceof LanguageModelCompatibility ? input : new LanguageModelCompatibility(input)
|
||||
}
|
||||
|
||||
export class LanguageModel<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
> {
|
||||
export class LanguageModel<Options extends ProviderOptions = ProviderOptions> {
|
||||
declare protected readonly _ProviderOptions: Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute<Compact>
|
||||
readonly route: AnyRoute
|
||||
readonly defaults?: LanguageModelDefaults
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
|
||||
constructor(input: LanguageModel.ConstructorInput<Compact>) {
|
||||
constructor(input: LanguageModel.ConstructorInput) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
@@ -192,11 +189,8 @@ export class LanguageModel<
|
||||
this.compatibility = input.compatibility
|
||||
}
|
||||
|
||||
static make<
|
||||
Options extends ProviderOptions = ProviderOptions,
|
||||
Compact extends CompactOperation | undefined = CompactOperation | undefined,
|
||||
>(input: LanguageModel.Input<Compact>) {
|
||||
return new LanguageModel<Options, Compact>({
|
||||
static make<Options extends ProviderOptions = ProviderOptions>(input: LanguageModel.Input) {
|
||||
return new LanguageModel<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
@@ -206,9 +200,7 @@ export class LanguageModel<
|
||||
})
|
||||
}
|
||||
|
||||
static input<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
): LanguageModel.ConstructorInput<Compact> {
|
||||
static input<Options extends ProviderOptions>(model: LanguageModel<Options>): LanguageModel.ConstructorInput {
|
||||
return {
|
||||
id: model.id,
|
||||
provider: model.provider,
|
||||
@@ -218,41 +210,25 @@ export class LanguageModel<
|
||||
}
|
||||
}
|
||||
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input<Compact>> & { readonly route: AnyRoute<Compact> },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions, Compact extends CompactOperation | undefined>(
|
||||
model: LanguageModel<Options, Compact>,
|
||||
patch: Partial<Omit<LanguageModel.Input, "route">> & { readonly route?: undefined },
|
||||
): LanguageModel<Options, Compact>
|
||||
static update<Options extends ProviderOptions>(
|
||||
model: LanguageModel<Options>,
|
||||
patch: Partial<LanguageModel.Input>,
|
||||
): LanguageModel<Options>
|
||||
static update<Options extends ProviderOptions>(model: LanguageModel<Options>, patch: Partial<LanguageModel.Input>) {
|
||||
if (Object.keys(patch).length === 0) return model
|
||||
return LanguageModel.make<Options>({
|
||||
...LanguageModel.input(model),
|
||||
...patch,
|
||||
route: patch.route ?? model.route,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace LanguageModel {
|
||||
export type ConstructorInput<Compact extends CompactOperation | undefined = CompactOperation | undefined> = {
|
||||
export type ConstructorInput = {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: AnyRoute<Compact>
|
||||
readonly route: AnyRoute
|
||||
readonly defaults?: LanguageModelDefaults
|
||||
readonly compatibility?: LanguageModelCompatibility
|
||||
}
|
||||
|
||||
export type Input<Compact extends CompactOperation | undefined = CompactOperation | undefined> = Omit<
|
||||
ConstructorInput<Compact>,
|
||||
"id" | "provider" | "defaults" | "compatibility"
|
||||
> & {
|
||||
export type Input = Omit<ConstructorInput, "id" | "provider" | "defaults" | "compatibility"> & {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
readonly defaults?: LanguageModelDefaults.Input
|
||||
|
||||
+11
-25
@@ -4,7 +4,6 @@ import { LLMClient } from "./route/client.js"
|
||||
import {
|
||||
LLMEvent,
|
||||
LLMResponse,
|
||||
CompactionResponse,
|
||||
type FinishReasonDetails,
|
||||
type AIError,
|
||||
type LLMRequest,
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
} from "./schema/index.js"
|
||||
import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect"
|
||||
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError> | CompactionResponse
|
||||
export type Response = readonly LLMEvent[] | Stream.Stream<LLMEvent, AIError>
|
||||
|
||||
export type Gate = Readonly<{ started: Effect.Effect<void>; release: Effect.Effect<void> }>
|
||||
|
||||
@@ -100,6 +99,8 @@ export const failAfter = (error: AIError, ...events: readonly LLMEvent[]) =>
|
||||
|
||||
export const hangAfter = (...events: readonly LLMEvent[]) => Stream.concat(Stream.fromIterable(events), Stream.never)
|
||||
|
||||
const toStream = (response: Response) => (Stream.isStream(response) ? response : Stream.fromIterable(response))
|
||||
|
||||
const make = (options: LayerOptions) =>
|
||||
Effect.sync(() => {
|
||||
const requests: LLMRequest[] = []
|
||||
@@ -112,41 +113,26 @@ const make = (options: LayerOptions) =>
|
||||
requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))),
|
||||
)
|
||||
|
||||
const take = (request: LLMRequest) =>
|
||||
Effect.suspend(() => {
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.suspend(() => {
|
||||
const count = requests.push(options.transformRequest?.(request) ?? request)
|
||||
const waiting = started
|
||||
started = Deferred.makeUnsafe()
|
||||
const gate = activeGate
|
||||
try {
|
||||
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback)
|
||||
if (!response) return Effect.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
if (!gate) return Effect.succeed(response)
|
||||
return Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(response))
|
||||
if (!response) return Stream.die(new Error(`TestLLM has no response for request ${count}`))
|
||||
const streamed = toStream(response)
|
||||
if (!gate) return streamed
|
||||
return Stream.unwrap(
|
||||
Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)),
|
||||
)
|
||||
} finally {
|
||||
// Waiters can resume synchronously; assign the reply and gate before notifying them.
|
||||
Deferred.doneUnsafe(waiting, Effect.void)
|
||||
}
|
||||
})
|
||||
const stream: ClientInterface["stream"] = (request) =>
|
||||
Stream.unwrap(
|
||||
take(request).pipe(
|
||||
Effect.map((response) => {
|
||||
if (response instanceof CompactionResponse)
|
||||
return Stream.die("TestLLM generation requires an event response")
|
||||
return Stream.isStream(response) ? response : Stream.fromIterable(response)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const test = Test.of({
|
||||
compact: (request) =>
|
||||
take(request).pipe(
|
||||
Effect.flatMap((response) =>
|
||||
response instanceof CompactionResponse
|
||||
? Effect.succeed(response)
|
||||
: Effect.die("TestLLM compaction requires a CompactionResponse"),
|
||||
),
|
||||
),
|
||||
stream,
|
||||
generate: (request) =>
|
||||
stream(request).pipe(
|
||||
|
||||
@@ -56,7 +56,6 @@ function normalizeToolMessage(message: Message, pending: Map<string, ToolCallPar
|
||||
role: message.role,
|
||||
content,
|
||||
metadata: message.metadata,
|
||||
providerMetadata: message.providerMetadata,
|
||||
native: message.native,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { LLM, LLMRequest, Message } from "../src/index.js"
|
||||
import { LLMClient } from "../src/route/client.js"
|
||||
import { OpenAI } from "../src/providers.js"
|
||||
import { testEffect } from "./lib/effect.js"
|
||||
import { runtimeLayer } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
|
||||
testEffect(runtimeLayer(FetchHttpClient.layer)).live("compaction and a tool loop work end to end over HTTP", () =>
|
||||
Effect.gen(function* () {
|
||||
const checkpoint = { type: "compaction", id: "cmp_local", encrypted_content: "opaque-local-state" }
|
||||
const calls: string[] = []
|
||||
const server = yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
const path = new URL(request.url).pathname
|
||||
calls.push(path)
|
||||
const body = await request.json()
|
||||
expect(request.headers.get("authorization")).toBe("Bearer fixture")
|
||||
if (path === "/v1/responses/compact") {
|
||||
expect(body.stream).toBeUndefined()
|
||||
return Response.json({
|
||||
object: "response.compaction",
|
||||
output: [checkpoint],
|
||||
usage: { input_tokens: 100, output_tokens: 10, total_tokens: 110 },
|
||||
})
|
||||
}
|
||||
expect(body.input[0]).toEqual(checkpoint)
|
||||
expect(body.stream).toBe(true)
|
||||
if (calls.length === 2)
|
||||
return new Response(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "{}" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
expect(body.input.at(-2)).toMatchObject({ type: "function_call", call_id: "call_1" })
|
||||
expect(body.input.at(-1)).toEqual({ type: "function_call_output", call_id: "call_1", output: "42" })
|
||||
const output = sseEvents(
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "The answer is 42." },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_1", content: [{ type: "output_text", text: "The answer is 42." }] },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_2" } },
|
||||
)
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(output.slice(0, 37)))
|
||||
controller.enqueue(new TextEncoder().encode(output.slice(37)))
|
||||
controller.close()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
(server) => Effect.sync(() => server.stop(true)),
|
||||
)
|
||||
const model = OpenAI.configure({ apiKey: "fixture", baseURL: `http://127.0.0.1:${server.port}/v1` }).responses(
|
||||
"fixture",
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "original",
|
||||
tools: [{ name: "lookup", description: "Lookup a number", inputSchema: { type: "object", properties: {} } }],
|
||||
})
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
const messages = [...compacted.replacement, Message.user("Look up the answer")]
|
||||
const first = yield* LLMClient.generate(LLMRequest.update(request, { messages }))
|
||||
expect(first.toolCalls).toHaveLength(1)
|
||||
const call = first.toolCalls[0]!
|
||||
const last = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...messages,
|
||||
first.message,
|
||||
Message.tool({ id: call.id, name: call.name, result: "42", resultType: "text" }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(last.text).toBe("The answer is 42.")
|
||||
expect(calls).toEqual(["/v1/responses/compact", "/v1/responses", "/v1/responses"])
|
||||
}),
|
||||
)
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { CompactionPart, CompactionResponse, LLMEvent, LLMResponse, Message, ProviderID } from "../src/schema/index.js"
|
||||
import { LLM, LLMClient, LLMRequest, LanguageModel } from "../src/index.js"
|
||||
import { OpenAI, Anthropic } from "../src/providers.js"
|
||||
|
||||
test("runtime capability checks follow model and route updates", () => {
|
||||
const supported = OpenAI.configure({ apiKey: "test" }).responses("fixture")
|
||||
const unsupported = Anthropic.configure({ apiKey: "test" }).model("fixture")
|
||||
const request = LLM.request({ model: supported, prompt: "hello" })
|
||||
expect(LLMClient.canCompact(request)).toBe(true)
|
||||
expect(LLMClient.canCompact(LLMRequest.update(request, { messages: [] }))).toBe(true)
|
||||
expect(LLMClient.canCompact(LLMRequest.update(request, { model: unsupported }))).toBe(false)
|
||||
expect(
|
||||
LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: unsupported.route }) })),
|
||||
).toBe(false)
|
||||
expect(LLMClient.canCompact(LLM.request({ model: LanguageModel.update(supported, { route: undefined }) }))).toBe(true)
|
||||
})
|
||||
|
||||
test("explicit compaction serializes a replacement window without a messages alias", () => {
|
||||
const response = new CompactionResponse({
|
||||
replacement: [
|
||||
Message.user("retained input"),
|
||||
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
|
||||
],
|
||||
})
|
||||
const codec = Schema.fromJsonString(CompactionResponse)
|
||||
const decoded = Schema.decodeSync(codec)(Schema.encodeSync(codec)(response))
|
||||
expect(decoded.replacement).toEqual(response.replacement)
|
||||
expect("messages" in decoded).toBe(false)
|
||||
})
|
||||
|
||||
test("compaction survives event assembly and message serialization without becoming text", () => {
|
||||
const part = CompactionPart.make({
|
||||
provider: ProviderID.make("openai"),
|
||||
id: "cmp_1",
|
||||
encrypted: "opaque",
|
||||
})
|
||||
const response = LLMResponse.fromEvents([
|
||||
LLMEvent.textStart({ id: "before" }),
|
||||
LLMEvent.textDelta({ id: "before", text: "Before" }),
|
||||
LLMEvent.textEnd({ id: "before" }),
|
||||
part,
|
||||
LLMEvent.textStart({ id: "after" }),
|
||||
LLMEvent.textDelta({ id: "after", text: "After" }),
|
||||
LLMEvent.textEnd({ id: "after" }),
|
||||
LLMEvent.finish({ reason: { normalized: "stop" } }),
|
||||
])!
|
||||
expect(response.message.content.map((part) => part.type)).toEqual(["text", "compaction", "text"])
|
||||
expect(response.text).toBe("BeforeAfter")
|
||||
expect(response.reasoning).toBe("")
|
||||
expect(response.events.filter(LLMEvent.is.compaction)).toEqual([part])
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(response.message))).toEqual(response.message)
|
||||
})
|
||||
|
||||
test("compaction requires exactly one typed representation", () => {
|
||||
const provider = ProviderID.make("anthropic")
|
||||
expect(CompactionPart.make({ provider, text: null })).toEqual({ type: "compaction", provider, text: null })
|
||||
const decode = Schema.decodeUnknownSync(CompactionPart)
|
||||
expect(() => decode({ type: "compaction", provider })).toThrow()
|
||||
expect(() => decode({ type: "compaction", provider, text: "summary", encrypted: "opaque" })).toThrow()
|
||||
})
|
||||
|
||||
test("tagged content and event guards accept both checkpoint representations", () => {
|
||||
for (const part of [
|
||||
CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "opaque" }),
|
||||
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: "summary" }),
|
||||
CompactionPart.make({ provider: ProviderID.make("anthropic"), text: null }),
|
||||
]) {
|
||||
expect(LLMEvent.is.compaction(part)).toBe(true)
|
||||
expect(LLMEvent.guards.compaction(part)).toBe(true)
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const message = Message.assistant(part)
|
||||
expect(Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))).toEqual(message)
|
||||
}
|
||||
})
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
CompactionPart,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ProviderID,
|
||||
} from "../../src/index.js"
|
||||
import { OpenAI, Azure, XAI, Anthropic, OpenAICompatibleResponses } from "../../src/providers.js"
|
||||
|
||||
const openai = OpenAI.configure({
|
||||
apiKey: "test",
|
||||
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
|
||||
}).responses("gpt-5.3-codex")
|
||||
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" }))
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure().responses("fixture"),
|
||||
Azure.configure({ resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure().responses("fixture"),
|
||||
OpenAI.model("fixture", {}),
|
||||
Azure.responsesModel("fixture", { resourceName: "test" }),
|
||||
XAI.model("fixture", {}),
|
||||
openai.route.with({ headers: { "x-test": "test" } }).model({ id: "fixture" }),
|
||||
LanguageModel.update(openai, { defaults: { generation: { maxTokens: 100 } } }),
|
||||
LanguageModel.make(LanguageModel.input(openai)),
|
||||
]) {
|
||||
LLMClient.compact(LLM.request({ model, prompt: "hello" }))
|
||||
}
|
||||
|
||||
const unsupported = {
|
||||
anthropic: LLM.request({ model: Anthropic.configure().model("fixture") }),
|
||||
openaiChat: LLM.request({ model: OpenAI.configure().chat("fixture") }),
|
||||
azureChat: LLM.request({ model: Azure.configure({ resourceName: "test" }).chat("fixture") }),
|
||||
xaiChat: LLM.request({ model: XAI.configure().chat("fixture") }),
|
||||
compatible: LLM.request({
|
||||
model: OpenAICompatibleResponses.configure({ baseURL: "https://example.com" }).model("fixture"),
|
||||
}),
|
||||
}
|
||||
// @ts-expect-error Anthropic has no standalone compact endpoint.
|
||||
LLMClient.compact(unsupported.anthropic)
|
||||
// @ts-expect-error Chat does not expose Responses compaction.
|
||||
LLMClient.compact(unsupported.openaiChat)
|
||||
// @ts-expect-error Azure Chat does not expose Responses compaction.
|
||||
LLMClient.compact(unsupported.azureChat)
|
||||
// @ts-expect-error xAI Chat does not expose Responses compaction.
|
||||
LLMClient.compact(unsupported.xaiChat)
|
||||
// @ts-expect-error Protocol compatibility does not guarantee endpoint support.
|
||||
LLMClient.compact(unsupported.compatible)
|
||||
LLMClient.Service.use((client) => {
|
||||
// @ts-expect-error The service enforces the same capability as the convenience function.
|
||||
return client.compact(unsupported.anthropic)
|
||||
})
|
||||
|
||||
const request = LLM.request({ model: openai, prompt: "hello" })
|
||||
LLMClient.compact(LLMRequest.update(request, { messages: [Message.user("continue")] }))
|
||||
LLMClient.compact(new LLMRequest(LLMRequest.input(request)))
|
||||
const switched = LLMRequest.update(request, { model: Anthropic.configure().model("fixture") })
|
||||
// @ts-expect-error Switching models replaces, rather than inherits, the capability.
|
||||
LLMClient.compact(switched)
|
||||
LLMClient.compact(LLMRequest.update(switched, { model: openai }))
|
||||
LLMClient.compact(
|
||||
// @ts-expect-error Replacing the route also replaces compaction capability.
|
||||
LLM.request({ model: LanguageModel.update(openai, { route: Anthropic.configure().model("fixture").route }) }),
|
||||
)
|
||||
|
||||
declare const dynamicModel: LanguageModel
|
||||
declare const dynamicPatch: Partial<LLMRequest.Input>
|
||||
const dynamicRequest = LLM.request({ model: dynamicModel, prompt: "hello" })
|
||||
// @ts-expect-error A dynamically selected model must be narrowed first.
|
||||
LLMClient.compact(dynamicRequest)
|
||||
if (LLMClient.canCompact(dynamicRequest)) LLMClient.compact(dynamicRequest)
|
||||
// @ts-expect-error An optional model override cannot retain the old capability statically.
|
||||
LLMClient.compact(LLMRequest.update(request, dynamicPatch))
|
||||
|
||||
const checkpoint = CompactionPart.make({ provider: ProviderID.make("openai"), id: "cmp_1", encrypted: "opaque" })
|
||||
const provider = ProviderID.make("anthropic")
|
||||
CompactionPart.make({ provider, text: "summary" })
|
||||
CompactionPart.make({ provider, text: null })
|
||||
// @ts-expect-error A checkpoint must have a representation.
|
||||
CompactionPart.make({ provider })
|
||||
// @ts-expect-error Encrypted and summary representations are mutually exclusive.
|
||||
CompactionPart.make({ provider, encrypted: "opaque", text: "summary" })
|
||||
// @ts-expect-error A failed summary cannot also carry encrypted content.
|
||||
LLMEvent.compaction({ provider, encrypted: "opaque", text: null })
|
||||
// @ts-expect-error The canonical message type also enforces the invariant.
|
||||
Message.assistant({ type: "compaction", provider })
|
||||
if (checkpoint.encrypted !== undefined) {
|
||||
checkpoint.encrypted satisfies string
|
||||
checkpoint.text satisfies undefined
|
||||
}
|
||||
if (checkpoint.text !== undefined) {
|
||||
checkpoint.text satisfies string | null
|
||||
checkpoint.encrypted satisfies undefined
|
||||
}
|
||||
checkpoint.encrypted
|
||||
// @ts-expect-error Compaction parts do not contain a generic provider payload.
|
||||
checkpoint.value
|
||||
LLMClient.compact(LLM.request({ model: openai, prompt: "hello" })).pipe(
|
||||
Effect.map((result) => {
|
||||
result.replacement satisfies ReadonlyArray<Message>
|
||||
// @ts-expect-error The replacement window is named explicitly; the old field is not an alias.
|
||||
result.messages
|
||||
// @ts-expect-error Compaction returns replacement history, not a synthetic assistant message.
|
||||
result.message
|
||||
}),
|
||||
)
|
||||
LLM.request({
|
||||
model: openai,
|
||||
providerOptions: {
|
||||
// @ts-expect-error A token threshold is numeric.
|
||||
contextManagement: [{ type: "compaction", compactThreshold: "100000" }],
|
||||
},
|
||||
})
|
||||
const anthropic = Anthropic.configure().model("claude-opus-4-6")
|
||||
LLM.request({
|
||||
model: anthropic,
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [{ type: "compact_20260112", pauseAfterCompaction: true, instructions: "Summarize without using tools" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
LLM.request({
|
||||
model: anthropic,
|
||||
providerOptions: {
|
||||
// @ts-expect-error A pause setting is boolean.
|
||||
contextManagement: { edits: [{ type: "compact_20260112", pauseAfterCompaction: "yes" }] },
|
||||
},
|
||||
})
|
||||
@@ -1,170 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { Anthropic, GoogleVertexMessages } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
for (const fixture of [
|
||||
{
|
||||
name: "empty iterations fall back to top-level usage",
|
||||
usage: { input_tokens: 2, output_tokens: 3, cache_read_input_tokens: null, iterations: [] },
|
||||
expected: { inputTokens: 2, outputTokens: 3, totalTokens: 5, contextTokens: undefined },
|
||||
},
|
||||
{
|
||||
name: "compaction-only usage has no post-compaction context size",
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
iterations: [{ type: "compaction", input_tokens: 7, cache_read_input_tokens: 3, output_tokens: 2 }],
|
||||
},
|
||||
expected: { inputTokens: 10, outputTokens: 2, totalTokens: 12, contextTokens: undefined },
|
||||
},
|
||||
{
|
||||
name: "partially reported iterations preserve known totals",
|
||||
usage: {
|
||||
iterations: [
|
||||
{ type: "compaction", input_tokens: 7, cache_creation_input_tokens: 2 },
|
||||
{ type: "message", output_tokens: 3 },
|
||||
],
|
||||
},
|
||||
expected: { inputTokens: 9, outputTokens: 3, totalTokens: 12, contextTokens: undefined },
|
||||
},
|
||||
{
|
||||
name: "missing counters remain unknown rather than zero",
|
||||
usage: { iterations: [{ type: "message" }] },
|
||||
expected: { inputTokens: undefined, outputTokens: undefined, totalTokens: undefined, contextTokens: undefined },
|
||||
},
|
||||
]) {
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: fixture.usage } },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
).effect(fixture.name, () =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
|
||||
prompt: "hello",
|
||||
}),
|
||||
)
|
||||
expect(result.usage).toMatchObject(fixture.expected)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const model of [
|
||||
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
|
||||
GoogleVertexMessages.configure({ accessToken: "test", project: "test" }).model("claude-opus-4-6"),
|
||||
]) {
|
||||
for (const summary of ["Summary of the conversation", null]) {
|
||||
const block = { type: "compaction", content: summary }
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
|
||||
if (body.messages.length === 1) {
|
||||
expect(body.context_management.edits).toEqual([
|
||||
{
|
||||
type: "compact_20260112",
|
||||
trigger: { type: "input_tokens", value: 50000 },
|
||||
pause_after_compaction: true,
|
||||
},
|
||||
])
|
||||
}
|
||||
if (body.messages.length > 1) {
|
||||
expect(body.messages[1].content).toEqual([block])
|
||||
expect(body.context_management).toBeUndefined()
|
||||
}
|
||||
return respond(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 50000, output_tokens: 0 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: summary } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "compaction" },
|
||||
usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 5,
|
||||
iterations: [
|
||||
{ type: "compaction", input_tokens: 50000, output_tokens: 1000, cache_read_input_tokens: 10 },
|
||||
{ type: "message", input_tokens: 1000, output_tokens: 5 },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
).effect(
|
||||
`${model.provider} replays ${summary === null ? "failed" : "successful"} compaction with billing and context usage`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
http: { headers: { "anthropic-beta": "existing-beta" } },
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [
|
||||
{
|
||||
type: "compact_20260112",
|
||||
trigger: { type: "input_tokens", value: 50000 },
|
||||
pauseAfterCompaction: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.raw).toBe("compaction")
|
||||
expect(first.message.content).toEqual([{ type: "compaction", provider: model.provider, text: summary }])
|
||||
expect(first.text).toBe("")
|
||||
expect(first.usage?.inputTokens).toBe(51010)
|
||||
expect(first.usage?.outputTokens).toBe(1005)
|
||||
expect(first.usage?.totalTokens).toBe(52015)
|
||||
expect(first.usage?.contextTokens).toBe(1000)
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
providerOptions: {},
|
||||
messages: [...request.messages, message, Message.user("continue")],
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const events of [
|
||||
[{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: 42 } }],
|
||||
[{ type: "content_block_delta", index: 0, delta: { type: "compaction_delta", content: "no start" } }],
|
||||
[
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "compaction", content: null } },
|
||||
{ type: "message_stop" },
|
||||
],
|
||||
]) {
|
||||
testEffect(fixedResponse(sseEvents(...events))).effect(
|
||||
`rejects malformed compaction lifecycle: ${JSON.stringify(events)}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient, WebSocketTransport } from "../../src/route.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
|
||||
testEffect(fixedResponse("unexpected HTTP fallback")).effect(
|
||||
"WebSocket responses preserve compaction options and replay state",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const checkpoint = { type: "compaction", id: "cmp_ws", encrypted_content: "opaque" }
|
||||
const sent: unknown[] = []
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(message)
|
||||
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
|
||||
expect(body.stream).toBeUndefined()
|
||||
if (sent.length) expect(body.input[1]).toEqual(checkpoint)
|
||||
sent.push(body)
|
||||
}),
|
||||
messages: Stream.fromIterable(
|
||||
[
|
||||
{ type: "response.created", response: { id: "resp_ws" } },
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{ type: "response.completed", response: { id: "resp_ws", output: [checkpoint] } },
|
||||
].map((event) => JSON.stringify(event)),
|
||||
),
|
||||
close: Effect.void,
|
||||
}),
|
||||
})
|
||||
const request = LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
prompt: "hello",
|
||||
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request, { webSocket })
|
||||
expect(first.message.content).toHaveLength(1)
|
||||
expect(first.message.content[0]?.type).toBe("compaction")
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { messages: [...request.messages, first.message, Message.user("continue")] }),
|
||||
{ webSocket },
|
||||
)
|
||||
expect(sent).toHaveLength(2)
|
||||
}),
|
||||
)
|
||||
@@ -1,91 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { OpenAI, XAI, Anthropic } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const history = [
|
||||
Message.user("Remember the project codename COPPER-ORBIT-42."),
|
||||
Message.assistant(
|
||||
"The project codename is COPPER-ORBIT-42. " + "We reviewed the implementation and tests. ".repeat(1000),
|
||||
),
|
||||
]
|
||||
|
||||
for (const provider of [
|
||||
{
|
||||
id: "openai",
|
||||
key: "OPENAI_API_KEY",
|
||||
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" }).responses("gpt-5.3-codex"),
|
||||
},
|
||||
{
|
||||
id: "xai",
|
||||
key: "XAI_API_KEY",
|
||||
model: XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }).responses("grok-4.6"),
|
||||
},
|
||||
]) {
|
||||
recordedTests({ prefix: `${provider.id}-compaction`, provider: provider.id, requires: [provider.key] }).effect(
|
||||
"compacts and continues with the provider checkpoint",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model: provider.model, messages: history, generation: { maxTokens: 1024 } })
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
const result = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...compacted.replacement,
|
||||
Message.user("What is the project codename? Reply only with the codename."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result.text).toContain("COPPER-ORBIT-42")
|
||||
}),
|
||||
120000,
|
||||
)
|
||||
}
|
||||
|
||||
recordedTests({
|
||||
prefix: "anthropic-compaction",
|
||||
provider: "anthropic",
|
||||
requires: ["ANTHROPIC_API_KEY"],
|
||||
options: { redact: { allowRequestHeaders: ["anthropic-version", "anthropic-beta"] } },
|
||||
}).effect(
|
||||
"automatically compacts and continues after a pause",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const model = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" }).model(
|
||||
"claude-sonnet-4-6",
|
||||
)
|
||||
const request = LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user(
|
||||
"Remember the project codename COPPER-ORBIT-42. " +
|
||||
"The implementation and tests were reviewed. ".repeat(10000),
|
||||
),
|
||||
],
|
||||
generation: { maxTokens: 4096 },
|
||||
providerOptions: {
|
||||
contextManagement: {
|
||||
edits: [
|
||||
{ type: "compact_20260112", trigger: { type: "input_tokens", value: 50000 }, pauseAfterCompaction: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.raw).toBe("compaction")
|
||||
expect(first.message.content.some((part) => part.type === "compaction")).toBe(true)
|
||||
const result = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
Message.user("What is the project codename? Reply only with the codename."),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(result.text).toContain("COPPER-ORBIT-42")
|
||||
}),
|
||||
120000,
|
||||
)
|
||||
@@ -1,136 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route/client.js"
|
||||
import { OpenAI, Azure, XAI } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const response = sseEvents(
|
||||
{ type: "response.output_item.done", item: checkpoint },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { id: "resp_1", output: [checkpoint], usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } },
|
||||
},
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(body.context_management).toEqual([{ type: "compaction", compact_threshold: 100000 }])
|
||||
expect(body.store).toBe(false)
|
||||
if (body.input.length > 1) expect(body.input[1]).toEqual(checkpoint)
|
||||
return respond(response, { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} compaction survives generation, serialization, and a second request`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
providerOptions: { contextManagement: [{ type: "compaction", compactThreshold: 100000 }] },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.message.content).toHaveLength(1)
|
||||
expect(first.message.content[0]?.type).toBe("compaction")
|
||||
expect(first.text).toBe("")
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const message = Schema.decodeSync(codec)(Schema.encodeSync(codec)(first.message))
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, { messages: [...request.messages, message, Message.user("continue")] }),
|
||||
)
|
||||
const rejected = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
|
||||
providerOptions: {},
|
||||
messages: [message],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(rejected.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse(sseEvents({ type: "response.completed", response: { output: [checkpoint] } }))).effect(
|
||||
"recovers compaction from the terminal output when item completion is absent",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
)
|
||||
expect(result.message.content).toHaveLength(1)
|
||||
expect(result.message.content[0]?.type).toBe("compaction")
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
fixedResponse(sseEvents({ type: "response.output_item.done", item: { type: "compaction", id: "cmp_bad" } })),
|
||||
).effect("rejects incomplete compaction payloads without publishing a checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toContain("cmp_bad")
|
||||
}),
|
||||
)
|
||||
|
||||
const textItem = {
|
||||
type: "message",
|
||||
id: "msg_after",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "After checkpoint" }],
|
||||
}
|
||||
|
||||
for (const completed of [false, true]) {
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { type: "compaction", id: checkpoint.id } },
|
||||
...(completed ? [{ type: "response.output_item.done", output_index: 0, item: checkpoint }] : []),
|
||||
{ type: "response.output_item.added", output_index: 1, item: textItem },
|
||||
{ type: "response.output_text.delta", output_index: 1, item_id: textItem.id, delta: "After checkpoint" },
|
||||
{ type: "response.output_item.done", output_index: 1, item: textItem },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [checkpoint, textItem] } },
|
||||
),
|
||||
),
|
||||
).effect(completed ? "keeps streamed checkpoints before later text" : "rejects order-unsafe terminal recovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
|
||||
if (completed) {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.message.content.map((part) => part.type)).toEqual(["compaction", "text"])
|
||||
return
|
||||
}
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("Cannot recover a compaction checkpoint")
|
||||
expect(error.reason.body).toContain("response.completed")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
type: "response.completed",
|
||||
response: { output: [{ type: "compaction", encrypted_content: "opaque" }] },
|
||||
}),
|
||||
),
|
||||
).effect("rejects terminal checkpoints missing an id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("missing its id")
|
||||
}),
|
||||
)
|
||||
@@ -1,30 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { OpenAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
it.effect("conversation lowering excludes generation settings and tool definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = yield* OpenResponses.lowerConversation(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
system: "Keep the context",
|
||||
messages: [Message.user("hello"), Message.assistant("hi")],
|
||||
generation: { maxTokens: 100, temperature: 0.5 },
|
||||
providerOptions: { store: false },
|
||||
tools: [{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } }],
|
||||
}),
|
||||
{ id: "open-responses", name: "Open Responses" },
|
||||
)
|
||||
expect(body).toEqual({
|
||||
model: "fixture",
|
||||
instructions: "Keep the context",
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "hello" }] },
|
||||
{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,447 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMRequest, Message } from "../../src/index.js"
|
||||
import { LLMClient, Route } from "../../src/route/client.js"
|
||||
import { Auth } from "../../src/route/auth.js"
|
||||
import { Endpoint } from "../../src/route/endpoint.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { OpenAI, Azure, XAI, Anthropic, OpenAICompatibleResponses } from "../../src/providers/index.js"
|
||||
import { testEffect } from "../lib/effect.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const checkpoint = { type: "compaction", id: "cmp_1", encrypted_content: "opaque" }
|
||||
const retained = {
|
||||
type: "message",
|
||||
role: "user",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
content: [{ type: "input_text", text: "retained" }],
|
||||
}
|
||||
const output = [retained, checkpoint]
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(request.headers["x-deployment"]).toBe("fixture")
|
||||
expect(request.headers["x-override"]).toBe("request")
|
||||
expect(request.headers["x-default"]).toBe("configured")
|
||||
expect(request.headers.authorization).toBe("Bearer test")
|
||||
expect(new URL(request.url).searchParams.get("api-version")).toBe("fixture")
|
||||
expect(new URL(request.url).searchParams.get("trace")).toBe("request")
|
||||
if (new URL(request.url).pathname.endsWith("/compact")) {
|
||||
expect(JSON.parse(text)).toEqual({
|
||||
model: "overlaid",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
instructions: "request instructions",
|
||||
previous_response_id: "resp_previous",
|
||||
})
|
||||
return respond(JSON.stringify({ object: "response.compaction", output }))
|
||||
}
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
|
||||
}),
|
||||
),
|
||||
).effect("generation and compaction share deployment headers, defaults, auth, query, and middleware", () =>
|
||||
Effect.gen(function* () {
|
||||
const headers: string[] = []
|
||||
const middleware: string[] = []
|
||||
const route = Route.make({
|
||||
id: "compaction-headers",
|
||||
provider: "openai",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
compact: OpenAIResponses.route.compact,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
endpoint: Endpoint.path(({ body }) => `/${body.model}/responses`, {
|
||||
baseURL: "https://example.com",
|
||||
query: { "api-version": "fixture" },
|
||||
}),
|
||||
auth: Auth.bearer("test"),
|
||||
headers: ({ request }) => {
|
||||
expect(request.providerOptions?.store).toBe(false)
|
||||
headers.push(String(request.model.id))
|
||||
return { "x-deployment": "fixture", "x-override": "route" }
|
||||
},
|
||||
defaults: {
|
||||
headers: { "x-default": "configured", "x-override": "configured" },
|
||||
providerOptions: { store: false },
|
||||
http: { body: { instructions: "default instructions" } },
|
||||
},
|
||||
})
|
||||
const request = LLM.request({
|
||||
model: route.model({ id: "fixture" }),
|
||||
prompt: "hello",
|
||||
system: "system instructions",
|
||||
http: {
|
||||
headers: { "x-override": "request" },
|
||||
query: { trace: "request" },
|
||||
body: {
|
||||
model: "overlaid",
|
||||
instructions: "request instructions",
|
||||
previous_response_id: "resp_previous",
|
||||
store: false,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const options: Parameters<typeof LLMClient.compact>[1] = {
|
||||
http: (request, next) => {
|
||||
middleware.push(new URL(request.url).pathname)
|
||||
return next(request)
|
||||
},
|
||||
}
|
||||
yield* LLMClient.generate(request, options)
|
||||
yield* LLMClient.compact(request, options)
|
||||
expect(headers).toEqual(["fixture", "fixture"])
|
||||
expect(middleware).toEqual(["/fixture/responses", "/fixture/responses/compact"])
|
||||
}),
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
]) {
|
||||
const item = {
|
||||
type: model.provider === "xai" ? "x_search_call" : "computer_call",
|
||||
id: "hosted_1",
|
||||
status: "completed",
|
||||
}
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
|
||||
expect(JSON.parse(text)).toEqual({ model: "fixture", input: [item], instructions: "Keep the context" })
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} compacts provider-specific history without lowering generation settings`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
system: "Keep the context",
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "tool-result",
|
||||
id: item.id,
|
||||
name: item.type,
|
||||
result: { type: "json", value: item },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { [model.route.providerMetadataKey ?? model.provider]: { itemId: item.id } },
|
||||
}),
|
||||
],
|
||||
})
|
||||
for (const candidate of [
|
||||
LLMRequest.update(request, {
|
||||
tools: [
|
||||
{ name: "unsupported", description: "Generation only", inputSchema: {}, native: { unsupported: {} } },
|
||||
],
|
||||
}),
|
||||
LLMRequest.update(request, { providerOptions: { contextManagement: "invalid-generation-option" } }),
|
||||
]) {
|
||||
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
const response = yield* LLMClient.compact(candidate)
|
||||
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const retainedItems = [
|
||||
retained,
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_assistant",
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
phase: "commentary",
|
||||
content: [
|
||||
{ type: "output_text", text: "First" },
|
||||
{ type: "output_text", text: "Second" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "Thinking" },
|
||||
{ type: "summary_text", text: "More thinking" },
|
||||
],
|
||||
encrypted_content: "reasoning-state",
|
||||
},
|
||||
{ type: "reasoning", id: "rs_2", summary: [], encrypted_content: "hidden-reasoning" },
|
||||
{
|
||||
type: "message",
|
||||
id: "msg_media",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_image", image_url: "https://example.com/image.png" },
|
||||
{ type: "input_file", filename: "report.pdf", file_data: "data:application/pdf;base64,cGRm", detail: "high" },
|
||||
{ type: "input_file", filename: "other.pdf", file_url: "https://example.com/report.pdf", detail: "low" },
|
||||
],
|
||||
},
|
||||
checkpoint,
|
||||
]
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
...[undefined, "custom"].map((providerMetadataKey) =>
|
||||
Route.make({
|
||||
id: providerMetadataKey ?? "default-metadata",
|
||||
provider: "openai",
|
||||
providerMetadataKey,
|
||||
protocol: OpenAIResponses.protocol,
|
||||
compact: OpenAIResponses.route.compact,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
}).model({ id: "fixture" }),
|
||||
),
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
if (new URL(request.url).pathname.endsWith("/compact"))
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: retainedItems }))
|
||||
expect(JSON.parse(text).input).toEqual(retainedItems)
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect(`${model.route.id} retains messages, reasoning, and media through typed conversation parts`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
prompt: "hello",
|
||||
})
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
expect(compacted.replacement.map((message) => message.role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
"assistant",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
])
|
||||
expect(compacted.replacement[1]?.content).toEqual([
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
])
|
||||
expect(compacted.replacement[2]?.content.map((part) => part.type)).toEqual(["reasoning", "reasoning"])
|
||||
expect(compacted.replacement[4]?.content.map((part) => part.type)).toEqual(["media", "media", "media"])
|
||||
const codec = Schema.fromJsonString(Schema.Array(Message))
|
||||
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement))
|
||||
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key: "overridden" }]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(JSON.parse(text)).toEqual({
|
||||
model: "fixture",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
service_tier: overlay?.service_tier ?? "flex",
|
||||
prompt_cache_key: overlay?.prompt_cache_key ?? "affinity",
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "explicit", ttl: "30m" },
|
||||
})
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
|
||||
}),
|
||||
),
|
||||
).effect(`compact preserves supported request controls${overlay ? " with HTTP overrides" : ""}`, () =>
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
prompt: "hello",
|
||||
promptCacheKey: "affinity",
|
||||
providerOptions: { serviceTier: "flex" },
|
||||
generation: { maxTokens: 100 },
|
||||
http: {
|
||||
body: {
|
||||
stream: true,
|
||||
store: false,
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "explicit", ttl: "30m" },
|
||||
...overlay,
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
for (const item of [
|
||||
{ type: "unknown_provider_item", data: "do not hide in a compaction part" },
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_image", image_url: "https://example.com/image.png", detail: 42 }],
|
||||
},
|
||||
{ type: "message", role: "user", content: [] },
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "input_image", image_url: "https://example.com/image.png" }],
|
||||
},
|
||||
{ type: "message", role: "user", content: [{ type: "input_file", filename: "missing.pdf" }] },
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_file", filename: "bad.pdf", file_url: "https://example.com/report.pdf", detail: 42 }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_file",
|
||||
filename: "both.pdf",
|
||||
file_url: "https://example.com/report.pdf",
|
||||
file_data: "data:application/pdf;base64,cGRm",
|
||||
},
|
||||
],
|
||||
},
|
||||
]) {
|
||||
testEffect(fixedResponse(JSON.stringify({ object: "response.compaction", output: [item, checkpoint] }))).effect(
|
||||
`rejects unsupported compact output: ${JSON.stringify(item)}`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toContain(JSON.stringify(item))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
]) {
|
||||
const images = [undefined, "low", "high", "auto"].map((detail) => ({
|
||||
type: "input_image",
|
||||
image_url: "https://example.com/image.png",
|
||||
...(detail === undefined ? {} : { detail }),
|
||||
}))
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
if (new URL(request.url).pathname.endsWith("/compact"))
|
||||
return respond(
|
||||
JSON.stringify({
|
||||
object: "response.compaction",
|
||||
output: [{ type: "message", role: "user", content: images }, checkpoint],
|
||||
}),
|
||||
)
|
||||
expect(JSON.parse(text).input[0].content).toEqual(images)
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1" } }))
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} preserves retained image detail through serialization and replay`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model, prompt: "hello" })
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
const codec = Schema.fromJsonString(Schema.Array(Message))
|
||||
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement))
|
||||
yield* LLMClient.generate(LLMRequest.update(request, { messages }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(fixedResponse("must not execute")).effect("xAI rejects automatic compaction options", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLMRequest.update(
|
||||
LLM.request({ model: XAI.configure({ apiKey: "test" }).responses("grok-4.6"), prompt: "hello" }),
|
||||
{ providerOptions: { contextManagement: [{ type: "compaction" }] } },
|
||||
)
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("LLMClient.compact")
|
||||
}),
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("deployment"),
|
||||
XAI.configure({ apiKey: "test" }).responses("grok-4.6"),
|
||||
]) {
|
||||
testEffect(
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(request.method).toBe("POST")
|
||||
expect(request.headers[model.provider === "azure" ? "api-key" : "authorization"]).toBe(
|
||||
model.provider === "azure" ? "test" : "Bearer test",
|
||||
)
|
||||
if (new URL(request.url).pathname.endsWith("/responses/compact")) {
|
||||
expect(body).toEqual({
|
||||
model: model.id,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "original" }] }],
|
||||
instructions: "system",
|
||||
})
|
||||
return respond(
|
||||
JSON.stringify({
|
||||
object: "response.compaction",
|
||||
output,
|
||||
usage: { input_tokens: 1000, output_tokens: 10, total_tokens: 1010 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}
|
||||
expect(new URL(request.url).pathname.endsWith("/responses")).toBe(true)
|
||||
expect(body.input).toEqual([...output, { role: "user", content: [{ type: "input_text", text: "continue" }] }])
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [] } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} explicitly compacts and replays the entire canonical window`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model, prompt: "original", system: "system", http: { body: { store: false } } })
|
||||
const compacted = yield* LLMClient.compact(request)
|
||||
expect(compacted.usage?.totalTokens).toBe(1010)
|
||||
expect(compacted.replacement.map((message) => message.role)).toEqual(["user", "assistant"])
|
||||
expect(compacted.replacement[0]?.content).toEqual([{ type: "text", text: "retained" }])
|
||||
expect(compacted.replacement[1]?.content).toEqual([
|
||||
{ type: "compaction", provider: model.provider, id: "cmp_1", encrypted: "opaque" },
|
||||
])
|
||||
const codec = Schema.fromJsonString(Schema.Array(Message))
|
||||
const messages = Schema.decodeSync(codec)(Schema.encodeSync(codec)(compacted.replacement))
|
||||
yield* LLMClient.generate(LLMRequest.update(request, { messages: [...messages, Message.user("continue")] }))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
for (const model of [
|
||||
Anthropic.configure({ apiKey: "test" }).model("claude-opus-4-6"),
|
||||
OpenAICompatibleResponses.configure({ apiKey: "test", baseURL: "https://compatible.example/v1" }).model("model"),
|
||||
]) {
|
||||
testEffect(fixedResponse("must not execute")).effect(
|
||||
`${model.route.id} does not inherit an unsupported compact endpoint`,
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
// @ts-expect-error Untyped callers must still receive the runtime capability error.
|
||||
const error = yield* LLMClient.compact(LLM.request({ model, prompt: "hello" })).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
testEffect(
|
||||
fixedResponse(JSON.stringify({ object: "response.compaction", output: [retained], debug: "original payload" })),
|
||||
).effect("invalid explicit compaction preserves the original response and HTTP context", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(
|
||||
LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("gpt-5.3-codex"), prompt: "hello" }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.reason.body).toContain("original payload")
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
@@ -1,60 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { OpenAI, Azure, XAI } from "../../src/providers.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
Azure.configure({ apiKey: "test", resourceName: "test" }).responses("fixture"),
|
||||
XAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
]) {
|
||||
it.effect(`${model.provider} preserves image detail through message serialization and lowering`, () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [undefined, "low", "high", "auto"]
|
||||
const message = Message.user(
|
||||
details.map((detail) => ({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "https://example.com/image.png",
|
||||
providerMetadata:
|
||||
detail === undefined ? undefined : { [model.route.providerMetadataKey ?? model.provider]: { detail } },
|
||||
})),
|
||||
)
|
||||
const codec = Schema.fromJsonString(Message)
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Schema.decodeSync(codec)(Schema.encodeSync(codec)(message))],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input[0].content).toEqual(
|
||||
details.map((detail) => ({
|
||||
type: "input_image",
|
||||
image_url: "https://example.com/image.png",
|
||||
detail,
|
||||
})),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects malformed image detail instead of silently discarding it", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
messages: [
|
||||
Message.user({
|
||||
type: "media",
|
||||
mediaType: "image/png",
|
||||
data: "https://example.com/image.png",
|
||||
providerMetadata: { openai: { detail: 42 } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
)
|
||||
@@ -1,18 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
CompactionResponse,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
ProviderID,
|
||||
RateLimitError,
|
||||
} from "../src/index.js"
|
||||
import { OpenAI } from "../src/providers.js"
|
||||
import { AIError, LanguageModel, LLM, LLMClient, LLMEvent, LLMRequest, RateLimitError } from "../src/index.js"
|
||||
import { OpenAIChat } from "../src/protocols/openai-chat.js"
|
||||
import { TestLLM } from "../src/testing.js"
|
||||
import { Effect, Fiber, Latch, Stream } from "effect"
|
||||
@@ -79,54 +66,6 @@ describe("TestLLM legacy client", () => {
|
||||
})
|
||||
|
||||
describe("TestLLM first-class client", () => {
|
||||
it.effect("rejects response fixtures for the wrong operation", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
|
||||
yield* client.push(TestLLM.stop(), new CompactionResponse({ replacement: [] }))
|
||||
expect(yield* client.compact(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM compaction requires a CompactionResponse",
|
||||
)
|
||||
expect(yield* client.generate(request).pipe(Effect.catchDefect(Effect.succeed))).toBe(
|
||||
"TestLLM generation requires an event response",
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("scripts replacement windows with the same lazy recording, gates, and fallback controls", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* TestLLM.Test
|
||||
const request = LLM.request({ model: OpenAI.configure({ apiKey: "test" }).responses("fixture"), prompt: "hello" })
|
||||
const compacted = new CompactionResponse({
|
||||
replacement: [
|
||||
Message.user("retained input"),
|
||||
Message.assistant(CompactionPart.make({ provider: ProviderID.make("openai"), encrypted: "checkpoint" })),
|
||||
Message.user("retained tail"),
|
||||
],
|
||||
})
|
||||
yield* client.push(compacted, TestLLM.text("continued", "answer"))
|
||||
const operation = LLMClient.compact(request)
|
||||
expect(yield* client.requests()).toEqual([])
|
||||
const gate = yield* client.gate()
|
||||
const fiber = yield* operation.pipe(Effect.forkChild({ startImmediately: true }))
|
||||
yield* gate.started
|
||||
yield* client.wait(1)
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
yield* gate.release
|
||||
expect(yield* Fiber.join(fiber)).toBe(compacted)
|
||||
const next = LLMRequest.update(request, { messages: compacted.replacement })
|
||||
expect((yield* LLMClient.generate(next)).text).toBe("continued")
|
||||
yield* client.serve((observed) => {
|
||||
expect(observed).toBe(next)
|
||||
return compacted
|
||||
})
|
||||
expect(yield* LLMClient.compact(next)).toBe(compacted)
|
||||
yield* client.always(compacted)
|
||||
expect(yield* LLMClient.compact(next)).toBe(compacted)
|
||||
expect(yield* client.requests()).toEqual([request, next, next, next])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("provides the same object under normal and test tags with snapshot observations", () =>
|
||||
Effect.gen(function* () {
|
||||
const llm = yield* TestLLM.Test
|
||||
|
||||
@@ -128,7 +128,6 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
bytecode: true,
|
||||
sourcemap: Script.channel === "dev" || Script.channel === "local" ? "inline" : "none",
|
||||
splitting: true,
|
||||
compile: {
|
||||
@@ -146,7 +145,6 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_CLI_NAME: `'${binary}'`,
|
||||
OPENCODE_CHANNEL: `'${Script.channel}'`,
|
||||
OPENCODE_ARTIFACT: `'cli'`,
|
||||
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
|
||||
// FFF_LIBC selects the fff native lib variant: "musl" or "gnu".
|
||||
FFF_LIBC: item.os === "linux" ? `'${item.abi ?? "gnu"}'` : "undefined",
|
||||
|
||||
@@ -23,13 +23,7 @@ async function publish(dir: string, name: string, version: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function publishDistribution(input: {
|
||||
root: string
|
||||
name: string
|
||||
binary: string
|
||||
packagePrefix: string
|
||||
artifact: string
|
||||
}) {
|
||||
async function publishDistribution(input: { root: string; name: string; binary: string; packagePrefix: string }) {
|
||||
const binaries: Record<string, string> = {}
|
||||
for (const filepath of new Bun.Glob("*/package.json").scanSync({ cwd: input.root })) {
|
||||
const item = await Bun.file(`${input.root}/${filepath}`).json()
|
||||
@@ -78,13 +72,6 @@ async function publishDistribution(input: {
|
||||
),
|
||||
)
|
||||
await publish(`${input.root}/${input.name}`, input.name, version)
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: input.artifact,
|
||||
distribution: "npm",
|
||||
version,
|
||||
metadata: { package: input.name },
|
||||
})
|
||||
}
|
||||
|
||||
await publishDistribution({
|
||||
@@ -92,7 +79,6 @@ await publishDistribution({
|
||||
name: pkg.name,
|
||||
binary: "opencode2",
|
||||
packagePrefix: "@opencode-ai/cli-",
|
||||
artifact: "cli",
|
||||
})
|
||||
if (existsSync("./dist/node")) {
|
||||
await publishDistribution({
|
||||
@@ -100,6 +86,12 @@ if (existsSync("./dist/node")) {
|
||||
name: "opencode-node",
|
||||
binary: "opencode2-node",
|
||||
packagePrefix: "@opencode-ai/cli-node-",
|
||||
artifact: "cli-node",
|
||||
})
|
||||
}
|
||||
await UpdateArtifact.publish({
|
||||
channel: Script.channel,
|
||||
name: "cli",
|
||||
distribution: "npm",
|
||||
version: Script.version,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
@@ -93,11 +93,7 @@ try {
|
||||
}
|
||||
|
||||
const output = await Promise.all(errors)
|
||||
// Windows can retain directory handles briefly after the service processes exit.
|
||||
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }).catch((cause: unknown) => {
|
||||
console.error("Failed to remove service smoke-test directory", cause)
|
||||
failure ??= cause
|
||||
})
|
||||
await fs.rm(root, { recursive: true, force: true })
|
||||
if (failure)
|
||||
throw new Error(output.filter(Boolean).join("\n") || "Compiled service lifecycle smoke test failed", {
|
||||
cause: failure,
|
||||
|
||||
@@ -400,8 +400,7 @@ function turnStart(messageID: string, slash: PreparedPrompt["slash"], skill: Ski
|
||||
|
||||
async function loadCatalog(client: OpenCodeClient, cwd: string): Promise<Catalog> {
|
||||
const location = { directory: cwd }
|
||||
await client.plugin.awaitActivation({ location })
|
||||
// Some providers discover models in the background after activation has settled.
|
||||
// Location plugins initialize asynchronously, so the first ACP request may observe an empty catalog.
|
||||
const deadline = Date.now() + 5_000
|
||||
let missing = "No models are available"
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
),
|
||||
)
|
||||
const updater = yield* Updater.Service
|
||||
if (!server.service) yield* updater.check().pipe(Effect.forkScoped)
|
||||
yield* updater.check().pipe(Effect.forkScoped)
|
||||
preflight.loading()
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
@@ -83,13 +83,13 @@ export default Runtime.handler(Commands, (input) =>
|
||||
get: () => runPromise(config.get()),
|
||||
update: (update) => runPromise(config.update(update)),
|
||||
},
|
||||
updater: service
|
||||
? {
|
||||
apply: (version) => runPromise(updater.apply(version)),
|
||||
}
|
||||
: undefined,
|
||||
packages: {
|
||||
prepare: (spec, install = true) => runPromise(install ? npm.add(spec) : npm.resolve(spec)),
|
||||
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(),
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 { Host } from "@opencode-ai/plugin/host"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { resolveConfigPath } from "../mcp/add"
|
||||
@@ -17,9 +16,9 @@ export default Runtime.handler(
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(input.package))))
|
||||
return yield* Effect.fail(new Error("Plugin target must be an npm registry package or Git package specifier"))
|
||||
const npm = yield* Npm.Service
|
||||
const installed = yield* npm.add(input.package)
|
||||
const entrypoints = Host.resolve(installed)
|
||||
const target = configurationTarget(entrypoints.server, entrypoints.tui)
|
||||
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}`))
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ export const inspect = Effect.fn("cli.plugin.inspect")(function* (selected?: str
|
||||
tuiTargets,
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const installed = yield* npm.resolve(target)
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
const outdated = yield* npm.check(target).pipe(Effect.exit)
|
||||
return {
|
||||
runtime: "TUI" as const,
|
||||
|
||||
@@ -9,9 +9,8 @@ import { ServiceConfig } from "../../../services/service-config"
|
||||
import { Config } from "../../../config"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { discoverPluginTargets, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
import { discoverTuiPlugins, localPluginDirectories, localSource } from "@opencode-ai/tui/plugin/discovery"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
@@ -23,27 +22,27 @@ export default Runtime.handler(
|
||||
const global = yield* Global.Service
|
||||
const info = yield* config.get()
|
||||
const discovered = yield* Effect.promise(() =>
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverPluginTargets),
|
||||
localPluginDirectories(process.cwd(), global.config).then(discoverTuiPlugins),
|
||||
)
|
||||
const npm = yield* Npm.Service
|
||||
const configured = yield* Effect.forEach([...(info.plugins ?? []), ...discovered], (entry) =>
|
||||
const configured = yield* Effect.forEach(info.plugins ?? [], (entry) =>
|
||||
Effect.gen(function* () {
|
||||
const target = typeof entry === "string" ? entry : entry.package
|
||||
if (target.startsWith("-") || target === "*" || target.endsWith(".*") || target.startsWith("opencode."))
|
||||
return []
|
||||
const local = localSource(target, path.dirname(config.path))
|
||||
if (local) {
|
||||
const directory = fileURLToPath(local)
|
||||
const entrypoints = Host.resolve({ directory })
|
||||
return entrypoints.tui ? [{ target: directory, version: "local" }] : []
|
||||
}
|
||||
if (local) return [{ target: fileURLToPath(local), version: "local" }]
|
||||
if (!(yield* Effect.promise(() => Npm.isInstallablePackage(target)))) return []
|
||||
const installed = yield* npm.resolve(target)
|
||||
if (!Host.resolve(installed).tui) return []
|
||||
const installed = yield* npm.resolve(target, { subpaths: ["tui"] })
|
||||
if (!installed.entrypoint) return []
|
||||
return [{ target, version: installed.version }]
|
||||
}),
|
||||
)
|
||||
const output = format(response.data, configured.flat(), input.builtin)
|
||||
const output = format(
|
||||
response.data,
|
||||
[...configured.flat(), ...discovered.map((target) => ({ target, version: "local" }))],
|
||||
input.builtin,
|
||||
)
|
||||
if (!output) {
|
||||
process.stdout.write("No plugins found" + EOL)
|
||||
return
|
||||
@@ -82,7 +81,9 @@ export function format(
|
||||
? plugin.source.target === item.target
|
||||
: plugin.source.type === "local" &&
|
||||
(plugin.source.path === item.target ||
|
||||
(plugin.features.tui && path.dirname(plugin.source.path) === item.target)),
|
||||
(plugin.features.tui &&
|
||||
path.dirname(plugin.source.path) ===
|
||||
(item.version === "local" && path.extname(item.target) ? path.dirname(item.target) : item.target))),
|
||||
),
|
||||
)
|
||||
.filter((plugin, index, all) => all.findIndex((candidate) => candidate.target === plugin.target) === index)
|
||||
|
||||
@@ -26,7 +26,7 @@ export default Runtime.handler(
|
||||
(item) =>
|
||||
(item.runtime === "Server"
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
|
||||
: npm.update(item.target).pipe(Effect.asVoid)
|
||||
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
|
||||
).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((result) => ({ item, result })),
|
||||
|
||||
@@ -7,8 +7,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import { spawn } from "node:child_process"
|
||||
import { Deferred, Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { Effect, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
@@ -54,8 +53,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
const global = yield* Global.Service
|
||||
if (options.mode === "service") yield* Effect.sync(() => process.chdir(global.home))
|
||||
const replacement = yield* Deferred.make<PersistentPty.Handoff | null>()
|
||||
const next = yield* Effect.scoped(
|
||||
return yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const foreground = options.mode === "default"
|
||||
const serviceOptions = options.mode === "service" ? yield* ServiceConfig.options() : undefined
|
||||
@@ -66,7 +64,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
serviceOptions !== undefined && port !== undefined
|
||||
? yield* Service.incumbent({ ...serviceOptions, url: serviceURL(hostname, port) })
|
||||
: undefined
|
||||
if (incumbent !== undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
if (incumbent !== undefined) return
|
||||
const { start } = yield* Effect.promise(() => import("@opencode-ai/server/process"))
|
||||
const environmentPassword = yield* Env.password
|
||||
// Keep the lease credential out of the environment inherited by tools.
|
||||
@@ -163,62 +161,19 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
)
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return Option.none<PersistentPty.Handoff | null>()
|
||||
if (server === undefined) return
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
const updater = yield* Updater.Service
|
||||
yield* updater
|
||||
.monitor({
|
||||
url,
|
||||
password,
|
||||
managed: options.mode === "service",
|
||||
notify: server.updateAvailable,
|
||||
restart: (handoff) => Deferred.succeed(replacement, handoff).pipe(Effect.asVoid),
|
||||
})
|
||||
.pipe(Effect.forkScoped)
|
||||
yield* updater.check().pipe(Effect.schedule(Schedule.spaced("10 minutes")), Effect.forkScoped)
|
||||
return yield* options.mode === "service"
|
||||
? Effect.raceFirst(
|
||||
server.shutdown.pipe(Effect.as(Option.none<PersistentPty.Handoff | null>())),
|
||||
Deferred.await(replacement).pipe(Effect.map(Option.some)),
|
||||
)
|
||||
? server.shutdown
|
||||
: options.mode === "stdio"
|
||||
? waitForStdinClose().pipe(Effect.as(Option.none<PersistentPty.Handoff | null>()))
|
||||
? waitForStdinClose()
|
||||
: Effect.never
|
||||
}).pipe(Effect.annotateLogs({ role: "server" })),
|
||||
)
|
||||
if (Option.isNone(next)) return
|
||||
yield* spawnReplacement(next.value)
|
||||
})
|
||||
|
||||
const spawnReplacement = Effect.fnUntraced(function* (handoff: PersistentPty.Handoff | null) {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const [command, ...args] = options.command
|
||||
if (!command) return yield* Effect.fail(new Error("Failed to resolve CLI command for restart"))
|
||||
// We do not monitor the replacement after spawn. A managed TUI
|
||||
// recovers with Service.ensure if startup fails; a future client
|
||||
// restart signal could coordinate that recovery instead.
|
||||
yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
...options.env,
|
||||
OPENCODE_PTY_HANDOFF: handoff ? JSON.stringify(handoff) : undefined,
|
||||
},
|
||||
})
|
||||
child.once("spawn", () => {
|
||||
child.unref()
|
||||
resolve()
|
||||
})
|
||||
child.once("error", reject)
|
||||
}),
|
||||
catch: (cause) => new Error("Failed to start replacement server", { cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
|
||||
|
||||
@@ -56,7 +56,7 @@ function managedService(options: EnsureOptions) {
|
||||
reconnect: () => Service.ensure(reconnectOptions),
|
||||
restart: () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Service.stop({ file: options.file, pty: "handoff" })
|
||||
yield* Service.stop(options)
|
||||
yield* Service.ensure(reconnectOptions)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type Policy = "disable" | "notify" | "auto"
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "notify" | "upgrade"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
@@ -6,14 +6,13 @@ const versionPattern =
|
||||
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === "disable") return "none"
|
||||
if (policy === false) return "none"
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
if (policy === "notify") return "notify"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (currentVersion.major !== latestVersion.major) return "notify"
|
||||
return "upgrade"
|
||||
if (currentVersion.major !== latestVersion.major) return "none"
|
||||
return policy === "notify" ? "notify" : "upgrade"
|
||||
}
|
||||
|
||||
export function parseReleaseVersion(input: string) {
|
||||
|
||||
@@ -3,54 +3,46 @@ import { action } from "./updater-action"
|
||||
import { decodePolicy } from "./updater"
|
||||
|
||||
describe("updater", () => {
|
||||
test("reads update policy from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "update": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "update": "disable" }')).toBe("disable")
|
||||
expect(decodePolicy('{ "update": "auto" }')).toBe("auto")
|
||||
expect(decodePolicy('{ "update": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("maps the v1 update policy", () => {
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe("disable")
|
||||
expect(decodePolicy('{ "autoupdate": "notify" }')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": true }')).toBe("auto")
|
||||
test("reads autoupdate from JSONC", () => {
|
||||
expect(decodePolicy('{ // preference\n "autoupdate": "notify",\n}')).toBe("notify")
|
||||
expect(decodePolicy('{ "autoupdate": false }')).toBe(false)
|
||||
expect(decodePolicy('{ "autoupdate": "invalid" }')).toBeUndefined()
|
||||
})
|
||||
|
||||
test("automatically updates patches and minors", () => {
|
||||
expect(action("1.2.3", "1.2.4", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.2.4", true)).toBe("upgrade")
|
||||
expect(action("1.2.3", "1.3.0", true)).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("reports patches and minors without automatically installing them", () => {
|
||||
expect(action("1.2.3", "1.2.4", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "1.3.0", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "2.0.0", "notify")).toBe("notify")
|
||||
expect(action("1.2.3", "1.2.3", "notify")).toBe("none")
|
||||
})
|
||||
|
||||
test("skips when updates are disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", "disable")).toBe("none")
|
||||
test("skips when autoupdate is disabled", () => {
|
||||
expect(action("1.2.3", "1.2.4", false)).toBe("none")
|
||||
})
|
||||
|
||||
test("reports majors instead of automatically installing them", () => {
|
||||
expect(action("1.2.3", "2.0.0", "auto")).toBe("notify")
|
||||
test("never automatically updates majors", () => {
|
||||
expect(action("1.2.3", "2.0.0", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("reports up-to-date only when versions match", () => {
|
||||
expect(action("1.2.3", "1.2.3", "auto")).toBe("none")
|
||||
expect(action("1.2.3", "1.2.3", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("accepts strict release version variants", () => {
|
||||
expect(action("v1.2.3", " 1.2.4\n", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", "auto")).toBe("upgrade")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3+old", "1.2.3+new", "auto")).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", "auto")).toBe("none")
|
||||
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
|
||||
expect(action("0.0.0-dev-17403", "0.0.0-dev-17403.2", true)).toBe("upgrade")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-beta-17404", true)).toBe("upgrade")
|
||||
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves strict validity", () => {
|
||||
@@ -71,21 +63,21 @@ describe("updater", () => {
|
||||
"0.9007199254740992.0",
|
||||
"0.0.9007199254740992",
|
||||
]
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, "auto"), version).toBe("none"))
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
|
||||
})
|
||||
|
||||
test("handles numeric limits without losing precision", () => {
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", "auto")).toBe("upgrade")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", "auto")).toBe("notify")
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves equality for oversized numeric prerelease identifiers", () => {
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", "auto")).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", "auto")).toBe("upgrade")
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("rejects versions longer than semver's limit before trimming", () => {
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, "auto")).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, "auto")).toBe("upgrade")
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,156 +1,27 @@
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { PersistentPty } from "@opencode-ai/schema/persistent-pty"
|
||||
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer, Ref, Schedule, Semaphore, Stream } from "effect"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_LOCAL, OPENCODE_VERSION } from "../version"
|
||||
import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, parseReleaseVersion, type Action, type Policy } from "./updater-action"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn"] as const
|
||||
export type Method = (typeof methods)[number]
|
||||
|
||||
const packageName =
|
||||
typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node" ? "opencode-node" : "@opencode-ai/cli"
|
||||
|
||||
export interface Interface {
|
||||
readonly check: () => Effect.Effect<void>
|
||||
readonly monitor: (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) => Effect.Effect<void>
|
||||
readonly apply: (version: string) => Effect.Effect<void, Error>
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
}
|
||||
|
||||
export type Inspection =
|
||||
| { readonly action: "none" }
|
||||
| { readonly action: Exclude<Action, "none">; readonly version: string }
|
||||
|
||||
type State =
|
||||
| { readonly type: "current" }
|
||||
| { readonly type: "available"; readonly version: string; readonly availableSince: number }
|
||||
| { readonly type: "ready-to-restart"; readonly version: string }
|
||||
|
||||
export interface MonitorInput {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly inspect: () => Effect.Effect<Inspection, Error>
|
||||
readonly install: (version: string) => Effect.Effect<boolean, Error>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
readonly interval?: Duration.Input
|
||||
readonly notificationThreshold?: Duration.Input
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export const monitorServer = Effect.fnUntraced(function* (input: MonitorInput) {
|
||||
const state = yield* Ref.make<State>({ type: "current" })
|
||||
const applyLock = yield* Semaphore.make(1)
|
||||
const client = OpenCode.make({
|
||||
baseUrl: input.url,
|
||||
headers: { authorization: `Basic ${btoa(`opencode:${input.password}`)}` },
|
||||
})
|
||||
|
||||
const applyIfIdle = () =>
|
||||
applyLock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const pending = yield* Ref.get(state)
|
||||
if (pending.type !== "available") return
|
||||
const active = yield* Effect.tryPromise({
|
||||
try: () => client.session.active(),
|
||||
catch: (cause) => new Error("Failed to read active sessions", { cause }),
|
||||
})
|
||||
if (Object.keys(active).length > 0) return
|
||||
const latest = yield* input.inspect()
|
||||
if (latest.action !== "upgrade") {
|
||||
yield* Ref.set(state, { type: "current" })
|
||||
return
|
||||
}
|
||||
const installed = yield* input
|
||||
.install(latest.version)
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("automatic update failed", { cause: error }).pipe(Effect.as(false)),
|
||||
),
|
||||
)
|
||||
if (!installed) return
|
||||
const handoff = input.managed
|
||||
? yield* Effect.tryPromise({
|
||||
try: () => client.experimental.persistentPty.handoff(),
|
||||
catch: (cause) => new Error("Failed to prepare persistent terminals for restart", { cause }),
|
||||
})
|
||||
: undefined
|
||||
yield* Ref.set(state, { type: "ready-to-restart", version: latest.version })
|
||||
if (handoff) yield* input.restart(handoff.handoff)
|
||||
}),
|
||||
)
|
||||
|
||||
const checkServer = Effect.gen(function* () {
|
||||
const result = yield* input.inspect()
|
||||
if (result.action === "notify") {
|
||||
yield* input.notify(result.version)
|
||||
return
|
||||
}
|
||||
if (result.action !== "upgrade") {
|
||||
yield* Ref.update(
|
||||
state,
|
||||
(current): State => (current.type === "ready-to-restart" ? current : { type: "current" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
yield* Ref.update(state, (current): State => {
|
||||
if (current.type === "ready-to-restart" && current.version === result.version) return current
|
||||
return {
|
||||
type: "available",
|
||||
version: result.version,
|
||||
availableSince: current.type === "available" ? current.availableSince : Date.now(),
|
||||
}
|
||||
})
|
||||
yield* applyIfIdle()
|
||||
const pending = yield* Ref.get(state)
|
||||
if (
|
||||
pending.type === "available" &&
|
||||
Date.now() - pending.availableSince >= Duration.toMillis(input.notificationThreshold ?? "3 days")
|
||||
)
|
||||
yield* input.notify(pending.version)
|
||||
}).pipe(Effect.catch((cause) => Effect.logWarning("automatic update check failed", { cause })))
|
||||
|
||||
const subscribe = Effect.suspend(() =>
|
||||
Stream.fromAsyncIterable(
|
||||
client.event.subscribe(),
|
||||
(cause) => new Error("Update event stream failed", { cause }),
|
||||
).pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (event.type === "server.connected") return applyIfIdle()
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
event.type !== "session.execution.failed" &&
|
||||
event.type !== "session.execution.interrupted"
|
||||
)
|
||||
return Effect.void
|
||||
return Effect.tryPromise({
|
||||
try: () => client.session.wait({ sessionID: event.data.sessionID }),
|
||||
catch: (cause) => new Error(`Failed to wait for Session ${event.data.sessionID}`, { cause }),
|
||||
}).pipe(Effect.andThen(applyIfIdle()))
|
||||
}),
|
||||
Effect.catch((cause) => Effect.logWarning("update event stream disconnected", { cause })),
|
||||
),
|
||||
).pipe(Effect.repeat(Schedule.spaced("1 second")))
|
||||
|
||||
return yield* Effect.all(
|
||||
[checkServer.pipe(Effect.repeat(Schedule.spaced(input.interval ?? "10 minutes"))), subscribe],
|
||||
{
|
||||
concurrency: "unbounded",
|
||||
discard: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
|
||||
export function decodePolicy(text: string): Policy | undefined {
|
||||
@@ -158,220 +29,157 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
// the location-scoped server configuration graph.
|
||||
const errors: ParseError[] = []
|
||||
const input: unknown = parse(text, errors, { allowTrailingComma: true })
|
||||
if (errors.length || typeof input !== "object" || input === null) return
|
||||
if ("update" in input) {
|
||||
const value = input.update
|
||||
if (value === "disable" || value === "notify" || value === "auto") return value
|
||||
return
|
||||
}
|
||||
if (!("autoupdate" in input)) return
|
||||
if (input.autoupdate === false) return "disable"
|
||||
if (input.autoupdate === "notify") return "notify"
|
||||
if (input.autoupdate === true) return "auto"
|
||||
if (errors.length || typeof input !== "object" || input === null || !("autoupdate" in input)) return
|
||||
const value = input.autoupdate
|
||||
if (typeof value === "boolean" || value === "notify") return value
|
||||
}
|
||||
|
||||
const make = Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const installedPackage = yield* Effect.gen(function* () {
|
||||
const executable = yield* fs.realPath(process.execPath)
|
||||
const directory = path.dirname(path.dirname(executable))
|
||||
const manifest: { name: string; bin?: Record<string, string> } = yield* fs
|
||||
.readFileString(path.join(directory, "package.json"))
|
||||
.pipe(Effect.flatMap((text) => Effect.try(() => JSON.parse(text))))
|
||||
if (Object.values(manifest.bin ?? {}).some((bin) => path.resolve(directory, bin) === executable))
|
||||
return manifest.name
|
||||
}).pipe(Effect.orElseSucceed(() => undefined))
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const channel = OPENCODE_CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
fs.readFileString(path.join(global.config, name)).pipe(
|
||||
Effect.map(decodePolicy),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
return values.findLast((value) => value !== undefined) ?? "auto"
|
||||
})
|
||||
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
maxOutputBytes: 100_000,
|
||||
maxErrorBytes: 100_000,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) => ({
|
||||
code: result.exitCode,
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})),
|
||||
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
|
||||
const readPolicy = Effect.fnUntraced(function* () {
|
||||
const values = yield* Effect.forEach(["config.json", "opencode.json", "opencode.jsonc"], (name) =>
|
||||
fs.readFileString(path.join(global.config, name)).pipe(
|
||||
Effect.map(decodePolicy),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
),
|
||||
)
|
||||
})
|
||||
return values.findLast((value) => value !== undefined) ?? true
|
||||
})
|
||||
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
const binary = path.join(
|
||||
global.home,
|
||||
".opencode",
|
||||
"bin",
|
||||
process.platform === "win32" ? "opencode2.exe" : "opencode2",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
if (!installedPackage) return
|
||||
const run = Effect.fnUntraced(function* (command: string[], timeout: Duration.Input = "10 seconds") {
|
||||
return yield* appProcess
|
||||
.run(ChildProcess.make(command[0], command.slice(1)), {
|
||||
timeout,
|
||||
maxOutputBytes: 100_000,
|
||||
maxErrorBytes: 100_000,
|
||||
})
|
||||
.pipe(
|
||||
Effect.map((result) => ({
|
||||
code: result.exitCode,
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})),
|
||||
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
|
||||
)
|
||||
})
|
||||
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", installedPackage] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", installedPackage] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(installedPackage))?.check.method
|
||||
})
|
||||
const method = Effect.fnUntraced(function* () {
|
||||
const binary = path.join(
|
||||
global.home,
|
||||
".opencode",
|
||||
"bin",
|
||||
process.platform === "win32" ? "opencode2.exe" : "opencode2",
|
||||
)
|
||||
if (path.resolve(process.execPath) === path.resolve(binary)) return "curl"
|
||||
|
||||
const release = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetch(
|
||||
`https://update.opencode.ai/api/${encodeURIComponent(channel)}/${encodeURIComponent(OPENCODE_ARTIFACT)}/npm`,
|
||||
{
|
||||
const checks: ReadonlyArray<{ method: Method; command: string[] }> = [
|
||||
{ method: "npm", command: ["npm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "pnpm", command: ["pnpm", "list", "-g", "--depth=0", packageName] },
|
||||
{ method: "bun", command: ["bun", "pm", "ls", "-g"] },
|
||||
{ method: "yarn", command: ["yarn", "global", "list"] },
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => run(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => result.result.stdout.includes(packageName))?.check.method
|
||||
})
|
||||
|
||||
const latest = Effect.fnUntraced(function* () {
|
||||
const response = yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
fetch(`https://update.opencode.ai/api/${encodeURIComponent(channel)}/cli/npm`, {
|
||||
headers: { "User-Agent": `opencode/${OPENCODE_VERSION}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
})
|
||||
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
const latest = () => release().pipe(Effect.map((data) => data.version))
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const packageName = (yield* release()).package
|
||||
const target = `${packageName}@${version}`
|
||||
if (installedPackage && packageName !== installedPackage && (method === "pnpm" || method === "yarn")) {
|
||||
return yield* Effect.fail(new Error(`Reinstall ${target} with ${method} to migrate from ${installedPackage}.`))
|
||||
}
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
// Keep the old package: uninstalling it can unlink the replacement command.
|
||||
npm: [
|
||||
"npm",
|
||||
"install",
|
||||
"--global",
|
||||
...(installedPackage && packageName !== installedPackage ? ["--force"] : []),
|
||||
target,
|
||||
],
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* run(["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"], "5 minutes")
|
||||
if (download.code !== 0) return download
|
||||
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const inspect = Effect.fnUntraced(function* (): Effect.fn.Return<Inspection, Error> {
|
||||
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? "")) {
|
||||
yield* Effect.logInfo("update check skipped", {
|
||||
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
}),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
})
|
||||
return { action: "none" }
|
||||
}
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === "disable") {
|
||||
yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
return { action: "none" }
|
||||
}
|
||||
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: OPENCODE_VERSION,
|
||||
latest: version,
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
const data = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
})
|
||||
if (typeof data !== "object" || data === null || !("version" in data) || typeof data.version !== "string") {
|
||||
return yield* Effect.fail(new Error("Update information did not include a version"))
|
||||
}
|
||||
return data.version
|
||||
})
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") {
|
||||
yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
return { action: "none" }
|
||||
}
|
||||
if (next === "notify") {
|
||||
yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
return { action: next, version }
|
||||
}
|
||||
return { action: next, version }
|
||||
})
|
||||
|
||||
const install = Effect.fnUntraced(function* (version: string) {
|
||||
const detected = yield* method()
|
||||
if (!detected) {
|
||||
yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
return false
|
||||
}
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
return true
|
||||
})
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
const target = `${packageName}@${version}`
|
||||
const commands: Record<Exclude<Method, "bun" | "curl">, string[]> = {
|
||||
npm: ["npm", "install", "--global", target],
|
||||
pnpm: ["pnpm", "add", "--global", `--allow-build=${packageName}`, target],
|
||||
yarn: ["yarn", "global", "add", target],
|
||||
}
|
||||
const result = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
return yield* run(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* fs.makeTempDirectoryScoped({ directory: global.cache, prefix: "update-" })
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* run(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
if (download.code !== 0) return download
|
||||
return yield* run(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
}
|
||||
return yield* run(commands[method], "5 minutes")
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
})
|
||||
|
||||
const apply = Effect.fn("cli.updater.apply")(function* (version: string) {
|
||||
if (!(yield* install(version))) return yield* Effect.fail(new Error("Installation method not found"))
|
||||
})
|
||||
const check = Effect.fn("cli.updater.check")(
|
||||
function* () {
|
||||
if (OPENCODE_LOCAL || ["1", "true"].includes(process.env.OPENCODE_DISABLE_AUTOUPDATE?.toLowerCase() ?? ""))
|
||||
return yield* Effect.logInfo("update check skipped", {
|
||||
reason: OPENCODE_LOCAL ? "local-install" : "disabled",
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
})
|
||||
const policy = yield* readPolicy()
|
||||
if (policy === false) return yield* Effect.logInfo("update check skipped", { reason: "policy-disabled" })
|
||||
|
||||
const check = Effect.fn("cli.updater.check")(
|
||||
function* () {
|
||||
const result = yield* inspect()
|
||||
if (result.action !== "upgrade") return
|
||||
yield* install(result.version)
|
||||
},
|
||||
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
|
||||
)
|
||||
return yield* Effect.gen(function* () {
|
||||
const version = yield* latest()
|
||||
yield* Effect.logInfo("update check", {
|
||||
current: OPENCODE_VERSION,
|
||||
latest: version,
|
||||
})
|
||||
const next = action(OPENCODE_VERSION, version, policy)
|
||||
if (next === "none") return yield* Effect.logInfo("update check done", { action: "up-to-date" })
|
||||
if (next === "notify")
|
||||
return yield* Effect.logInfo("OpenCode update available", { current: OPENCODE_VERSION, latest: version })
|
||||
const detected = yield* method()
|
||||
if (!detected) return yield* Effect.logWarning("automatic update skipped: installation method not found")
|
||||
yield* upgrade(detected, version)
|
||||
yield* Effect.logInfo("updated OpenCode", { from: OPENCODE_VERSION, to: version, method: detected })
|
||||
})
|
||||
},
|
||||
Effect.catchCause((cause) => Effect.logWarning("automatic update failed", { cause })),
|
||||
)
|
||||
|
||||
const monitor = Effect.fn("cli.updater.monitor")(function* (input: {
|
||||
readonly url: string
|
||||
readonly password: string
|
||||
readonly managed: boolean
|
||||
readonly notify: (version: string) => Effect.Effect<void>
|
||||
readonly restart: (handoff: PersistentPty.Handoff | null) => Effect.Effect<void>
|
||||
}) {
|
||||
return yield* monitorServer({ ...input, inspect, install })
|
||||
})
|
||||
|
||||
return Service.of({ check, monitor, apply, method, latest, upgrade })
|
||||
})
|
||||
|
||||
export const layer = Layer.effect(Service, make)
|
||||
return Service.of({ check, method, latest, upgrade })
|
||||
}),
|
||||
)
|
||||
|
||||
export * as Updater from "./updater"
|
||||
export { action, type Action, type Policy } from "./updater-action"
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
declare const OPENCODE_VERSION: string
|
||||
declare const OPENCODE_CHANNEL: string
|
||||
declare const OPENCODE_ARTIFACT: string
|
||||
|
||||
const version = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
const channel = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||
const artifact = typeof OPENCODE_ARTIFACT === "string" ? OPENCODE_ARTIFACT : "cli"
|
||||
|
||||
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL, artifact as OPENCODE_ARTIFACT }
|
||||
export { version as OPENCODE_VERSION, channel as OPENCODE_CHANNEL }
|
||||
export const OPENCODE_LOCAL = channel === "local"
|
||||
|
||||
@@ -1,50 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { McpServer, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import { makeACPFixture, makeSession, secondModel, testModel } from "./service-fixture"
|
||||
import { makeACPFixture, makeSession, secondModel } from "./service-fixture"
|
||||
import { flattenSelectOptions, requireSelectOption } from "./subprocess"
|
||||
|
||||
describe("acp service directory behavior", () => {
|
||||
test("does not cache an available model before plugin activation settles", async () => {
|
||||
const requested = Promise.withResolvers<void>()
|
||||
const release = Promise.withResolvers<void>()
|
||||
let ready = false
|
||||
await using fixture = makeACPFixture({
|
||||
fetch(request) {
|
||||
requested.resolve()
|
||||
if (request.path === "/api/plugin/await-activation") {
|
||||
return release.promise.then(() => {
|
||||
ready = true
|
||||
return new Response(null, { status: 204 })
|
||||
})
|
||||
}
|
||||
if (!ready && request.path === "/api/model") {
|
||||
return Response.json({ data: [{ ...testModel, providerID: "ambient" }] })
|
||||
}
|
||||
if (!ready && request.path === "/api/model/default") {
|
||||
return Response.json({ data: { ...testModel, providerID: "ambient" } })
|
||||
}
|
||||
if (request.path === "/api/session" && request.method === "POST") {
|
||||
return Response.json({ data: { ...makeSession("ses_ready"), model: undefined } })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const pending = fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })
|
||||
try {
|
||||
await requested.promise
|
||||
expect(fixture.requests.map((request) => request.path)).toEqual(["/api/plugin/await-activation"])
|
||||
expect(fixture.requests[0]?.query["location[directory]"]).toBe("/workspace")
|
||||
release.resolve()
|
||||
expect(currentValue(await pending, "model")).toBe("test/test-model")
|
||||
expect(
|
||||
fixture.requests.find((request) => request.path === "/api/session" && request.method === "POST")?.body,
|
||||
).toMatchObject({ model: { providerID: "test", id: "test-model" } })
|
||||
} finally {
|
||||
release.resolve()
|
||||
await pending.catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
test("creates sessions from a catalog shared by concurrent callers in the same cwd", async () => {
|
||||
let created = 0
|
||||
await using fixture = makeACPFixture({
|
||||
@@ -68,14 +27,7 @@ describe("acp service directory behavior", () => {
|
||||
expect(currentValue(first[0], "model")).toBe("test/test-model")
|
||||
expect(currentValue(first[0], "mode")).toBe("build")
|
||||
expect(
|
||||
[
|
||||
"/api/plugin/await-activation",
|
||||
"/api/model",
|
||||
"/api/model/default",
|
||||
"/api/agent",
|
||||
"/api/command",
|
||||
"/api/skill",
|
||||
].map((path) =>
|
||||
["/api/model", "/api/model/default", "/api/agent", "/api/command", "/api/skill"].map((path) =>
|
||||
fixture.requests
|
||||
.filter((request) => request.path === path)
|
||||
.map((request) => request.query["location[directory]"]),
|
||||
@@ -86,7 +38,6 @@ describe("acp service directory behavior", () => {
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
["/workspace", "/other"],
|
||||
])
|
||||
expect(
|
||||
fixture.requests
|
||||
|
||||
@@ -152,7 +152,6 @@ export function makeACPFixture(options: FixtureOptions = {}) {
|
||||
|
||||
const directory = request.query["location[directory]"] ?? "/workspace"
|
||||
const location = { directory, project: { id: "global", directory } }
|
||||
if (request.path === "/api/plugin/await-activation") return new Response(null, { status: 204 })
|
||||
if (request.path === "/api/event") {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
return new Response(
|
||||
|
||||
@@ -18,7 +18,6 @@ describe("acp service", () => {
|
||||
body: request.method === "GET" ? undefined : await request.json().catch(() => undefined),
|
||||
})
|
||||
const location = { directory: "/workspace", project: { id: "global", directory: "/workspace" } }
|
||||
if (url.pathname === "/api/plugin/await-activation") return new Response(null, { status: 204 })
|
||||
if (url.pathname === "/api/model") return Response.json({ location, data: [model] })
|
||||
if (url.pathname === "/api/model/default") return Response.json({ location, data: model })
|
||||
if (url.pathname === "/api/agent") return Response.json({ location, data: [agent] })
|
||||
|
||||
@@ -179,7 +179,7 @@ function verifierConfig(llmUrl: string, skills?: string) {
|
||||
limit: { context: 100_000, output: 10_000 },
|
||||
}
|
||||
return {
|
||||
update: "disable",
|
||||
autoupdate: false,
|
||||
model: "test/test-model",
|
||||
...(skills ? { skills: [skills] } : {}),
|
||||
providers: {
|
||||
|
||||
@@ -13,8 +13,6 @@ await Effect.runPromise(
|
||||
).pipe(
|
||||
Effect.provideService(Updater.Service, {
|
||||
check: () => Effect.die("Manual upgrades must not run the automatic update check"),
|
||||
monitor: () => Effect.die("Manual upgrades must not monitor automatic updates"),
|
||||
apply: () => Effect.die("Manual upgrades must not apply automatic updates"),
|
||||
method: () =>
|
||||
Effect.sync(() => {
|
||||
record("method")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { expect, spyOn, test } from "bun:test"
|
||||
import { expect, test } from "bun:test"
|
||||
import { Effect, FileSystem, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { existsSync } from "node:fs"
|
||||
@@ -17,29 +17,11 @@ function fixture(
|
||||
respond: (command: ChildProcess.StandardCommand) => Partial<AppProcess.RunResult> & {
|
||||
error?: AppProcess.AppProcessError
|
||||
} = () => ({}),
|
||||
name = "@opencode-ai/cli",
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const root = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-updater-" })
|
||||
const executable = path.join(root, "package", "bin", "opencode")
|
||||
yield* fs.makeDirectory(path.dirname(executable), { recursive: true })
|
||||
yield* fs.writeFileString(
|
||||
path.join(root, "package", "package.json"),
|
||||
JSON.stringify({ name, bin: { opencode: "bin/opencode" } }),
|
||||
)
|
||||
// The updater uses global fetch; scope this replacement to each install test.
|
||||
yield* Effect.acquireRelease(
|
||||
Effect.sync(() =>
|
||||
spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(async () => Response.json({ version: "2.3.4", metadata: { package: name } }), {
|
||||
preconnect: fetch.preconnect,
|
||||
}),
|
||||
),
|
||||
),
|
||||
(request) => Effect.sync(() => request.mockRestore()),
|
||||
)
|
||||
const global = Global.make({
|
||||
home: path.join(root, "home"),
|
||||
data: path.join(root, "data"),
|
||||
@@ -55,10 +37,6 @@ function fixture(
|
||||
const updater = yield* Updater.Service.pipe(
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provideService(Global.Service, global),
|
||||
Effect.provideService(FileSystem.FileSystem, {
|
||||
...fs,
|
||||
realPath: (input) => (input === process.execPath ? Effect.succeed(executable) : fs.realPath(input)),
|
||||
}),
|
||||
Effect.provideService(
|
||||
AppProcess.Service,
|
||||
AppProcess.Service.of({
|
||||
@@ -236,12 +214,9 @@ test("Node distribution honors the compile-time CLI name", async () => {
|
||||
if (typeof OPENCODE_CLI_NAME === "string" && OPENCODE_CLI_NAME === "opencode2-node") {
|
||||
it.live("Node distribution resolves the published npm package", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* fixture(
|
||||
(command) => ({
|
||||
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
|
||||
}),
|
||||
"opencode-node",
|
||||
)
|
||||
const test = yield* fixture((command) => ({
|
||||
stdout: Buffer.from(command.command === "npm" ? "opencode-node@2.3.4" : ""),
|
||||
}))
|
||||
expect(yield* test.updater.method()).toBe("npm")
|
||||
yield* test.updater.upgrade("npm", "v2.3.4")
|
||||
yield* test.updater.upgrade("pnpm", "v2.3.4")
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Option } from "effect"
|
||||
import { testEffect } from "../../core/test/lib/effect"
|
||||
import { Updater } from "../src/services/updater"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
it.live("installs and restarts after the final Session settles", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixture = yield* Effect.acquireRelease(Effect.sync(makeServer), (server) => Effect.sync(() => server.stop()))
|
||||
const installed = yield* Deferred.make<string>()
|
||||
const restarted = yield* Deferred.make<void>()
|
||||
yield* Updater.monitorServer({
|
||||
url: fixture.url,
|
||||
password: "test",
|
||||
managed: true,
|
||||
inspect: () => Effect.succeed({ action: "upgrade", version: "1.1.0" }),
|
||||
install: (version) => Deferred.succeed(installed, version).pipe(Effect.as(true)),
|
||||
restart: () => Deferred.succeed(restarted, undefined).pipe(Effect.asVoid),
|
||||
notify: () => Effect.void,
|
||||
}).pipe(Effect.forkScoped)
|
||||
yield* wait(fixture.activeRead, () => "Updater did not check active Sessions")
|
||||
yield* wait(fixture.eventOpened, () => "Updater did not open the server event stream")
|
||||
expect(Option.isNone(yield* Deferred.poll(installed))).toBe(true)
|
||||
|
||||
fixture.settle()
|
||||
yield* wait(fixture.waited, () => "Updater did not receive the settlement event")
|
||||
expect(
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(installed),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not install the update")))),
|
||||
),
|
||||
).toBe("1.1.0")
|
||||
yield* Effect.raceFirst(
|
||||
Deferred.await(restarted),
|
||||
Effect.sleep("1 second").pipe(Effect.andThen(Effect.fail(new Error("Updater did not restart the server")))),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const wait = (promise: Promise<unknown>, message: () => string) =>
|
||||
Effect.tryPromise(() => Promise.race([promise, Bun.sleep(1_000).then(() => Promise.reject(new Error(message())))]))
|
||||
|
||||
function makeServer() {
|
||||
const encoder = new TextEncoder()
|
||||
const activeRead = Promise.withResolvers<void>()
|
||||
const eventOpened = Promise.withResolvers<void>()
|
||||
const waited = Promise.withResolvers<void>()
|
||||
let active = true
|
||||
let events: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/session/active") {
|
||||
activeRead.resolve()
|
||||
return Response.json({ data: active ? { ses_test: { type: "running" } } : {} })
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_test/wait" && request.method === "POST") {
|
||||
waited.resolve()
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/experimental/persistent-pty/handoff" && request.method === "POST") {
|
||||
return Response.json({ handoff: null })
|
||||
}
|
||||
if (url.pathname === "/api/event") {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
events = controller
|
||||
eventOpened.resolve()
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
return new Response("Not found", { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
url: server.url.origin,
|
||||
activeRead: activeRead.promise,
|
||||
eventOpened: eventOpened.promise,
|
||||
waited: waited.promise,
|
||||
settle() {
|
||||
active = false
|
||||
events?.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({
|
||||
id: "evt_settled",
|
||||
created: Date.now(),
|
||||
type: "session.execution.succeeded",
|
||||
durable: { aggregateID: "ses_test", seq: 0, version: 1 },
|
||||
data: { sessionID: "ses_test" },
|
||||
})}\n\n`,
|
||||
),
|
||||
)
|
||||
events?.close()
|
||||
events = undefined
|
||||
},
|
||||
stop() {
|
||||
server.stop(true)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -268,7 +268,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
OPENCODE_VERSION: JSON.stringify(input.version),
|
||||
OPENCODE_CLI_NAME: JSON.stringify("opencode2-node"),
|
||||
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
||||
OPENCODE_ARTIFACT: JSON.stringify("cli-node"),
|
||||
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
||||
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
||||
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
|
||||
|
||||
@@ -88,14 +88,6 @@ export type PluginListInput = {
|
||||
export type PluginListOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Plugin.Info> }
|
||||
export type PluginListOperation<E = never> = (input?: PluginListInput) => Effect.Effect<PluginListOutput, E>
|
||||
|
||||
export type PluginAwaitActivationInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type PluginAwaitActivationOutput = void
|
||||
export type PluginAwaitActivationOperation<E = never> = (
|
||||
input?: PluginAwaitActivationInput,
|
||||
) => Effect.Effect<PluginAwaitActivationOutput, E>
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target?: string | undefined
|
||||
@@ -112,7 +104,6 @@ export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Eff
|
||||
|
||||
export interface PluginApi<E = never> {
|
||||
readonly list: PluginListOperation<E>
|
||||
readonly awaitActivation: PluginAwaitActivationOperation<E>
|
||||
readonly check: PluginCheckOperation<E>
|
||||
readonly update: PluginUpdateOperation<E>
|
||||
}
|
||||
|
||||
@@ -15,8 +15,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginAwaitActivationInput,
|
||||
PluginAwaitActivationOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
@@ -328,11 +326,6 @@ const EndpointPluginList = (raw: RawClient["server.plugin"]) => (input?: PluginL
|
||||
raw["plugin.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginAwaitActivation = (raw: RawClient["server.plugin"]) => (input?: PluginAwaitActivationInput) =>
|
||||
preserveEffect<PluginAwaitActivationOutput>()(
|
||||
raw["plugin.awaitActivation"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: PluginCheckInput) =>
|
||||
preserveEffect<PluginCheckOutput>()(
|
||||
raw["plugin.check"]({ query: { location: input?.["location"] }, payload: { target: input?.["target"] } }).pipe(
|
||||
@@ -349,7 +342,6 @@ const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: Plugin
|
||||
|
||||
const adaptGroupPlugin = (raw: RawClient["server.plugin"]) => ({
|
||||
list: EndpointPluginList(raw),
|
||||
awaitActivation: EndpointPluginAwaitActivation(raw),
|
||||
check: EndpointPluginCheck(raw),
|
||||
update: EndpointPluginUpdate(raw),
|
||||
})
|
||||
|
||||
@@ -9,8 +9,6 @@ import type {
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
PluginAwaitActivationInput,
|
||||
PluginAwaitActivationOutput,
|
||||
PluginCheckInput,
|
||||
PluginCheckOutput,
|
||||
PluginUpdateInput,
|
||||
@@ -472,18 +470,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
awaitActivation: (input?: PluginAwaitActivationInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginAwaitActivationOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/plugin/await-activation`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
check: (input?: PluginCheckInput, requestOptions?: RequestOptions) =>
|
||||
request<PluginCheckOutput>(
|
||||
{
|
||||
|
||||
@@ -985,6 +985,15 @@ export type ReferenceUpdated = {
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type PluginAdded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "plugin.added"
|
||||
location?: LocationRef
|
||||
data: { id: string }
|
||||
}
|
||||
|
||||
export type PluginUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1883,7 +1892,7 @@ export type ConfigEntry =
|
||||
shell?: string
|
||||
model?: string | { providerID: string; model: string; variant?: string }
|
||||
default_agent?: string
|
||||
update?: "disable" | "notify" | "auto"
|
||||
autoupdate?: boolean | "notify"
|
||||
share?: "manual" | "auto" | "disabled"
|
||||
enterprise?: { url?: string }
|
||||
username?: string
|
||||
@@ -2290,6 +2299,7 @@ export type V2Event =
|
||||
| ReferenceUpdated
|
||||
| PermissionAsked
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectUpdated
|
||||
| WorktreeUpdated
|
||||
@@ -2575,14 +2585,6 @@ export type PluginListOutput = {
|
||||
data: Array<PluginInfo>
|
||||
}
|
||||
|
||||
export type PluginAwaitActivationInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type PluginAwaitActivationOutput = void
|
||||
|
||||
export type PluginCheckInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -83,8 +83,10 @@ type LocationData = {
|
||||
agent?: AgentInfo[]
|
||||
command?: CommandInfo[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcpServer?: McpServer[]
|
||||
mcpResource?: McpResource[]
|
||||
mcp?: {
|
||||
server?: McpServer[]
|
||||
resource?: McpResource[]
|
||||
}
|
||||
model?: ModelInfo[]
|
||||
provider?: ProviderInfo[]
|
||||
reference?: ReferenceInfo[]
|
||||
@@ -123,8 +125,8 @@ export function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
|
||||
function locationQuery(ref: LocationRef) {
|
||||
return { directory: ref.directory, workspace: ref.workspaceID }
|
||||
function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
}
|
||||
|
||||
function formRequestOptions(sessionID: string, ref?: LocationRef) {
|
||||
@@ -1271,41 +1273,6 @@ export function createData(config: CreateDataInput) {
|
||||
}
|
||||
}
|
||||
|
||||
// A cached per-location catalog. `sync` loads once per invalidation, keyed by the
|
||||
// effective location, and publishes under the server's canonical location; `alias`
|
||||
// also publishes under the requested key when the two differ.
|
||||
function locationResource<Field extends keyof LocationData>(
|
||||
field: Field,
|
||||
load: (location: ReturnType<typeof locationQuery>) => Promise<{ location: LocationRef; data: LocationData[Field] }>,
|
||||
options?: { alias?: boolean },
|
||||
) {
|
||||
const publish = (key: string, value: LocationData[Field]) => setStore("location", key, { [field]: value })
|
||||
return {
|
||||
list: (ref?: LocationRef) => store.location[locationKey(ref ?? defaultLocation())]?.[field],
|
||||
sync: (ref?: LocationRef) => {
|
||||
const location = ref ?? defaultLocation()
|
||||
const id = locationKey(location)
|
||||
return sync.run(`location.${field}:${id}`, async () => {
|
||||
const response = await load(locationQuery(location))
|
||||
const key = locationKey(response.location)
|
||||
publish(key, response.data)
|
||||
if (options?.alias && key !== id) publish(id, response.data)
|
||||
})
|
||||
},
|
||||
invalidate: (ref?: LocationRef) => sync.invalidate(`location.${field}:${locationKey(ref ?? defaultLocation())}`),
|
||||
}
|
||||
}
|
||||
|
||||
const vcs = locationResource("vcs", (location) => api().vcs.get({ location }))
|
||||
const shells = locationResource("shell", async (location) => {
|
||||
const response = await api().shell.list({ location })
|
||||
const ref = { directory: response.location.directory, workspaceID: response.location.workspaceID }
|
||||
return {
|
||||
location: response.location,
|
||||
data: Object.fromEntries(response.data.map((info) => [info.id, { ...info, location: ref }])),
|
||||
}
|
||||
})
|
||||
|
||||
const result = {
|
||||
on: config.event.on,
|
||||
listen: config.event.listen,
|
||||
@@ -1773,7 +1740,7 @@ export function createData(config: CreateDataInput) {
|
||||
},
|
||||
shell: {
|
||||
list(location?: LocationRef) {
|
||||
return Object.values(shells.list(location) ?? {})
|
||||
return Object.values(store.location[locationKey(location ?? defaultLocation())]?.shell ?? {})
|
||||
},
|
||||
listBySession(sessionID: string) {
|
||||
return Object.values(store.location)
|
||||
@@ -1785,8 +1752,31 @@ export function createData(config: CreateDataInput) {
|
||||
.map((data) => data.shell?.[id])
|
||||
.find((shell) => shell !== undefined)
|
||||
},
|
||||
sync: shells.sync,
|
||||
invalidate: shells.invalidate,
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.shell:${id}`, async () => {
|
||||
const response = await api().shell.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
shell: Object.fromEntries(
|
||||
response.data.map((info) => [
|
||||
info.id,
|
||||
{
|
||||
...info,
|
||||
location: {
|
||||
directory: response.location.directory,
|
||||
workspaceID: response.location.workspaceID,
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.shell:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
location: {
|
||||
info(ref?: LocationRef) {
|
||||
@@ -1841,20 +1831,162 @@ export function createData(config: CreateDataInput) {
|
||||
result.shell.invalidate(location)
|
||||
result.session.form.invalidate("global", location)
|
||||
},
|
||||
vcs: { info: vcs.list, sync: vcs.sync, invalidate: vcs.invalidate },
|
||||
agent: locationResource("agent", (location) => api().agent.list({ location })),
|
||||
command: locationResource("command", (location) => api().command.list({ location })),
|
||||
integration: locationResource("integration", (location) => api().integration.list({ location })),
|
||||
vcs: {
|
||||
info(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.vcs
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const location = ref ?? defaultLocation()
|
||||
return sync.run(`location.vcs:${locationKey(location)}`, async () => {
|
||||
const response = await api().vcs.get({ location: locationQuery(location) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], vcs: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.vcs:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
agent: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.agent
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.agent:${id}`, async () => {
|
||||
const response = await api().agent.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], agent: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.agent:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
command: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.command
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.command:${id}`, async () => {
|
||||
const response = await api().command.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], command: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.command:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
integration: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.integration
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.integration:${id}`, async () => {
|
||||
const response = await api().integration.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], integration: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.integration:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
server: locationResource("mcpServer", (location) => api().mcp.list({ location })),
|
||||
resource: locationResource("mcpResource", async (location) => {
|
||||
const response = await api().mcp.resource.catalog({ location })
|
||||
return { location: response.location, data: response.data.resources }
|
||||
}),
|
||||
server: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.server
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.mcp.server:${id}`, async () => {
|
||||
const response = await api().mcp.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, server: response.data },
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.mcp.server:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
resource: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.mcp?.resource
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.mcp.resource:${id}`, async () => {
|
||||
const response = await api().mcp.resource.catalog({
|
||||
location: locationQuery(ref ?? defaultLocation()),
|
||||
})
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
mcp: { ...store.location[key]?.mcp, resource: response.data.resources },
|
||||
})
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.mcp.resource:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.model
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.model:${id}`, async () => {
|
||||
const response = await api().model.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], model: response.data })
|
||||
if (key !== id) setStore("location", id, { ...store.location[id], model: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.model:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
provider: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.provider
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.provider:${id}`, async () => {
|
||||
const response = await api().provider.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], provider: response.data })
|
||||
if (key !== id) setStore("location", id, { ...store.location[id], provider: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.provider:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
reference: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.reference
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.reference:${id}`, async () => {
|
||||
const response = await api().reference.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], reference: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.reference:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
model: locationResource("model", (location) => api().model.list({ location }), { alias: true }),
|
||||
provider: locationResource("provider", (location) => api().provider.list({ location }), { alias: true }),
|
||||
reference: locationResource("reference", (location) => api().reference.list({ location })),
|
||||
websearch: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.websearch
|
||||
@@ -1869,7 +2001,22 @@ export function createData(config: CreateDataInput) {
|
||||
})
|
||||
},
|
||||
},
|
||||
skill: locationResource("skill", (location) => api().skill.list({ location })),
|
||||
skill: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.skill
|
||||
},
|
||||
sync(ref?: LocationRef) {
|
||||
const id = locationKey(ref ?? defaultLocation())
|
||||
return sync.run(`location.skill:${id}`, async () => {
|
||||
const response = await api().skill.list({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(response.location)
|
||||
setStore("location", key, { ...store.location[key], skill: response.data })
|
||||
})
|
||||
},
|
||||
invalidate(ref?: LocationRef) {
|
||||
sync.invalidate(`location.skill:${locationKey(ref ?? defaultLocation())}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -20378,6 +20378,44 @@
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugin.added": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"allOf": [
|
||||
{
|
||||
"pattern": "^evt_"
|
||||
}
|
||||
]
|
||||
},
|
||||
"created": {
|
||||
"type": "number"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["plugin.added"]
|
||||
},
|
||||
"location": {
|
||||
"$ref": "#/components/schemas/Location.Ref"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "created", "type", "data"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"plugin.updated": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23085,6 +23123,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/permission.v2.replied"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/plugin.added"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/plugin.updated"
|
||||
},
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"migration": "bun run script/migration.ts",
|
||||
"fix-node-pty": "bun run script/fix-node-pty.ts",
|
||||
"benchmark:location": "bun run script/benchmark-location.ts",
|
||||
"benchmark:location-memory": "bun run script/benchmark-location-memory.ts",
|
||||
"build": "bun run script/build.ts",
|
||||
"update-models-snapshot": "bun run script/update-models-snapshot.ts",
|
||||
"test": "bun run script/test.ts",
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
// Measures the heap retained by Location service graphs and by the models.dev
|
||||
// catalog plugin. Everything runs against a temporary global directory with a
|
||||
// temporary home, an in-memory database, no filesystem watchers, and no network,
|
||||
// so it never touches a live server, database, or user configuration.
|
||||
//
|
||||
// bun run script/benchmark-location-memory.ts [--locations 6] [--plugins 8] [--json out.json]
|
||||
//
|
||||
// "retained" numbers are heapUsed after two forced GCs; "peak" numbers are the
|
||||
// highest heapUsed sampled without forcing GC and are reported separately.
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { heapStats } from "bun:jsc"
|
||||
import { Effect, Layer, Logger, Scope } from "effect"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { AppNodeBuilder } from "../src/effect/app-node-builder"
|
||||
import { Bus } from "../src/bus"
|
||||
import { Catalog } from "../src/catalog"
|
||||
import { Database } from "../src/database/database"
|
||||
import { Integration } from "../src/integration"
|
||||
import { Location } from "../src/location"
|
||||
import { LocationServiceMap } from "../src/location-service-map"
|
||||
import { ModelsDev } from "../src/models-dev"
|
||||
import { Plugin } from "../src/plugin"
|
||||
import { ModelsDevPlugin } from "../src/plugin/models-dev"
|
||||
import { AbsolutePath } from "../src/schema"
|
||||
import { Watcher } from "../src/filesystem/watcher"
|
||||
import { location } from "../test/fixture/location"
|
||||
import { catalogHost, host, integrationHost } from "../test/plugin/host"
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const flag = (name: string, fallback: number) => {
|
||||
const index = args.indexOf(`--${name}`)
|
||||
if (index === -1) return fallback
|
||||
const value = Number(args[index + 1])
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
console.error(`--${name} must be a positive integer`)
|
||||
process.exit(1)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const locationCount = flag("locations", 6)
|
||||
const pluginCount = flag("plugins", 8)
|
||||
const jsonIndex = args.indexOf("--json")
|
||||
const jsonPath = jsonIndex === -1 ? undefined : args[jsonIndex + 1]
|
||||
|
||||
type Sample = { heapUsed: number; rss: number; objects: number }
|
||||
|
||||
const sample = (): Sample => {
|
||||
Bun.gc(true)
|
||||
Bun.gc(true)
|
||||
const usage = process.memoryUsage()
|
||||
return { heapUsed: usage.heapUsed, rss: usage.rss, objects: heapStats().objectCount }
|
||||
}
|
||||
|
||||
const mib = (bytes: number) => (bytes / 1024 / 1024).toFixed(2).padStart(8)
|
||||
|
||||
const median = (values: ReadonlyArray<number>) => {
|
||||
const sorted = values.toSorted((a, b) => a - b)
|
||||
const middle = Math.floor(sorted.length / 2)
|
||||
const upper = sorted[middle] ?? 0
|
||||
return sorted.length % 2 === 0 ? ((sorted[middle - 1] ?? 0) + upper) / 2 : upper
|
||||
}
|
||||
|
||||
const root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "opencode-location-memory-")))
|
||||
const globalLayer = Global.layerWith({
|
||||
home: path.join(root, "home"),
|
||||
data: path.join(root, "data"),
|
||||
cache: path.join(root, "cache"),
|
||||
config: path.join(root, "config"),
|
||||
state: path.join(root, "state"),
|
||||
tmp: path.join(root, "tmp"),
|
||||
bin: path.join(root, "cache", "bin"),
|
||||
log: path.join(root, "data", "log"),
|
||||
repos: path.join(root, "data", "repos"),
|
||||
})
|
||||
const replacements = [
|
||||
Global.node.replace(globalLayer),
|
||||
ModelsDev.node.replace(ModelsDev.configured({ fetch: false })),
|
||||
Watcher.node.replace(Watcher.configured({ enabled: false })),
|
||||
]
|
||||
|
||||
// One full Location graph per directory, retained for the rest of the run, the
|
||||
// way a long-running server retains every directory a client has touched.
|
||||
const locationsProgram = Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const scope = yield* Scope.Scope
|
||||
const before = sample()
|
||||
const deltas: number[] = []
|
||||
const rss: number[] = []
|
||||
let previous = before
|
||||
for (let index = 0; index < locationCount; index++) {
|
||||
const directory = path.join(root, "projects", `location-${index}`)
|
||||
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
|
||||
const context = yield* locations
|
||||
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(directory) }))
|
||||
.pipe(Scope.provide(scope))
|
||||
const plugins = yield* Plugin.Service.pipe(Effect.provideContext(context))
|
||||
yield* plugins.awaitActivation
|
||||
const current = sample()
|
||||
deltas.push(current.heapUsed - previous.heapUsed)
|
||||
rss.push(current.rss)
|
||||
previous = current
|
||||
}
|
||||
const catalog = yield* Catalog.Service.pipe(
|
||||
Effect.provideContext(
|
||||
yield* locations
|
||||
.contextEffect(Location.Ref.make({ directory: AbsolutePath.make(path.join(root, "projects", `location-0`)) }))
|
||||
.pipe(Scope.provide(scope)),
|
||||
),
|
||||
)
|
||||
const models = yield* catalog.model.all()
|
||||
const providers = yield* catalog.provider.all()
|
||||
return {
|
||||
before,
|
||||
after: previous,
|
||||
deltas,
|
||||
rss,
|
||||
catalog: { providers: providers.length, models: models.length },
|
||||
}
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
// The models.dev plugin alone, against a real Catalog and Integration state per
|
||||
// instance, isolates the catalog-copy contribution from the rest of the graph.
|
||||
const pluginProgram = Effect.gen(function* () {
|
||||
const modelsDev = yield* ModelsDev.Service
|
||||
const snapshot = yield* modelsDev.get()
|
||||
const scope = yield* Scope.Scope
|
||||
const before = sample()
|
||||
const deltas: number[] = []
|
||||
let previous = before
|
||||
for (let index = 0; index < pluginCount; index++) {
|
||||
const directory = AbsolutePath.make(path.join(root, "plugins", `instance-${index}`))
|
||||
const locationLayer = Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location(Location.Ref.make({ directory }))),
|
||||
)
|
||||
const context = yield* Layer.build(
|
||||
AppNodeBuilder.build(LayerNode.group([Catalog.node, Integration.node, Bus.node]), [
|
||||
Location.node.replace(locationLayer),
|
||||
...replacements,
|
||||
]),
|
||||
).pipe(Scope.provide(scope))
|
||||
const catalog = yield* Catalog.Service.pipe(Effect.provideContext(context))
|
||||
const integration = yield* Integration.Service.pipe(Effect.provideContext(context))
|
||||
yield* ModelsDevPlugin.effect(
|
||||
host({ catalog: catalogHost(catalog), integration: integrationHost(integration) }),
|
||||
).pipe(Effect.provideService(ModelsDev.Service, modelsDev), Effect.provideContext(context), Scope.provide(scope))
|
||||
yield* catalog.model.all()
|
||||
yield* integration.list()
|
||||
const current = sample()
|
||||
deltas.push(current.heapUsed - previous.heapUsed)
|
||||
previous = current
|
||||
}
|
||||
return {
|
||||
before,
|
||||
after: previous,
|
||||
deltas,
|
||||
snapshot: {
|
||||
providers: snapshot.length,
|
||||
models: snapshot.reduce((total, provider) => total + provider.models.length, 0),
|
||||
},
|
||||
}
|
||||
}).pipe(Effect.scoped)
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const plugin = yield* pluginProgram.pipe(
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, ModelsDev.node]), replacements)),
|
||||
)
|
||||
const locations = yield* locationsProgram.pipe(
|
||||
Effect.provide(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, LocationServiceMap.node]), replacements),
|
||||
),
|
||||
)
|
||||
return { plugin, locations }
|
||||
}).pipe(Effect.provide(Logger.layer([])))
|
||||
|
||||
const result = await Effect.runPromise(program)
|
||||
await fs.rm(root, { recursive: true, force: true }).catch(() => undefined)
|
||||
|
||||
console.log(
|
||||
`models.dev snapshot: ${result.plugin.snapshot.providers} providers, ${result.plugin.snapshot.models} models`,
|
||||
)
|
||||
console.log(`ModelsDevPlugin instances: ${pluginCount}`)
|
||||
console.log(` retained heap per instance (MiB): ${result.plugin.deltas.map((delta) => mib(delta).trim()).join(", ")}`)
|
||||
console.log(` median per instance: ${mib(median(result.plugin.deltas))} MiB`)
|
||||
console.log(
|
||||
`Location graphs: ${locationCount} (catalog ${result.locations.catalog.providers} providers, ${result.locations.catalog.models} models each)`,
|
||||
)
|
||||
console.log(
|
||||
` retained heap per location (MiB): ${result.locations.deltas.map((delta) => mib(delta).trim()).join(", ")}`,
|
||||
)
|
||||
console.log(` median per location: ${mib(median(result.locations.deltas))} MiB`)
|
||||
console.log(
|
||||
` heapUsed before ${mib(result.locations.before.heapUsed)} MiB -> after ${mib(result.locations.after.heapUsed)} MiB`,
|
||||
)
|
||||
console.log(` rss before ${mib(result.locations.before.rss)} MiB -> after ${mib(result.locations.after.rss)} MiB`)
|
||||
|
||||
if (jsonPath) {
|
||||
await fs.mkdir(path.dirname(jsonPath), { recursive: true })
|
||||
await fs.writeFile(
|
||||
jsonPath,
|
||||
JSON.stringify(
|
||||
{ revision: process.env.OPENCODE_BENCH_REVISION, bun: Bun.version, locationCount, pluginCount, ...result },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
}
|
||||
+11
-11
@@ -34,7 +34,7 @@ type Data = {
|
||||
default?: ID
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Info[]
|
||||
get: (id: ID) => Info | undefined
|
||||
default: (id: ID | undefined) => void
|
||||
@@ -42,7 +42,7 @@ export type Editor = {
|
||||
remove: (id: ID) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
readonly resolve: (id?: ID | string) => Effect.Effect<Info | undefined>
|
||||
readonly select: (id?: ID | string) => Effect.Effect<Selection>
|
||||
@@ -62,32 +62,32 @@ const layer = Layer.effect(
|
||||
{ action: "external_directory", resource: path.join(global.tmp, "*"), effect: "allow" },
|
||||
{ action: "external_directory", resource: path.join(global.config, "*"), effect: "allow" },
|
||||
]
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "agent",
|
||||
initial: () => ({ agents: new Map() }),
|
||||
editor: (editor) => ({
|
||||
list: () => Array.fromIterable(editor.agents.values()) as Info[],
|
||||
get: (id) => editor.agents.get(id),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.fromIterable(draft.agents.values()) as Info[],
|
||||
get: (id) => draft.agents.get(id),
|
||||
default: (id) => {
|
||||
editor.default = id
|
||||
draft.default = id
|
||||
},
|
||||
update: (id, fn) => {
|
||||
const defaults = Info.default(id)
|
||||
const current =
|
||||
editor.agents.get(id) ??
|
||||
draft.agents.get(id) ??
|
||||
({
|
||||
...defaults,
|
||||
permissions: [...defaults.permissions, ...permissions],
|
||||
} as Types.DeepMutable<Info>)
|
||||
if (!editor.agents.has(id)) editor.agents.set(id, current)
|
||||
if (!draft.agents.has(id)) draft.agents.set(id, current)
|
||||
fn(current)
|
||||
current.id = id
|
||||
},
|
||||
remove: (id) => {
|
||||
editor.agents.delete(id)
|
||||
draft.agents.delete(id)
|
||||
},
|
||||
}),
|
||||
notify: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Agent.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const selectable = (agent: Info | undefined) =>
|
||||
agent && agent.mode !== "subagent" && !agent.hidden ? agent : undefined
|
||||
|
||||
@@ -306,7 +306,6 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
const providerID = info.canonical ?? info.providerID
|
||||
const optionKey = providerOptionKey(packageName, providerID)
|
||||
const route: AnyRoute = {
|
||||
compact: undefined,
|
||||
id: `ai-sdk:${packageName}`,
|
||||
provider: ProviderID.make(providerID),
|
||||
providerMetadataKey: optionKey,
|
||||
@@ -331,12 +330,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
},
|
||||
body: {
|
||||
schema: Schema.Unknown,
|
||||
from: (request) =>
|
||||
Effect.try({
|
||||
try: () => callOptions(request, packageName, info.modelID ?? info.id, optionKey),
|
||||
catch: (cause) =>
|
||||
cause instanceof AIError ? cause : ProviderShared.invalidRequest("Invalid AI SDK request", cause),
|
||||
}),
|
||||
from: (request) => Effect.succeed(callOptions(request, packageName, info.modelID ?? info.id, optionKey)),
|
||||
},
|
||||
with: () => route,
|
||||
model: (input) =>
|
||||
@@ -521,8 +515,6 @@ function userPart(part: ContentPart): UserContent {
|
||||
|
||||
function assistantPart(part: ContentPart): AssistantContent {
|
||||
switch (part.type) {
|
||||
case "compaction":
|
||||
throw ProviderShared.invalidRequest("AI SDK routes cannot replay native provider compaction state")
|
||||
case "text":
|
||||
return [{ type: "text", text: part.text, providerOptions: metadataProviderOptions(part.providerMetadata) }]
|
||||
case "media":
|
||||
|
||||
@@ -23,7 +23,7 @@ type Data = {
|
||||
defaultModel?: DefaultModel
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
provider: {
|
||||
list: () => readonly ProviderRecord[]
|
||||
get: (providerID: Provider.ID) => ProviderRecord | undefined
|
||||
@@ -41,7 +41,7 @@ export type Editor = {
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly provider: {
|
||||
readonly get: (providerID: Provider.ID) => Effect.Effect<Provider.Info | undefined>
|
||||
readonly all: () => Effect.Effect<Provider.Info[]>
|
||||
@@ -82,39 +82,39 @@ const layer = Layer.effect(
|
||||
} satisfies Model.Info
|
||||
}
|
||||
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "catalog",
|
||||
initial: () => ({ providers: new Map() }),
|
||||
editor: (editor) => {
|
||||
const result: Editor = {
|
||||
draft: (draft) => {
|
||||
const result: Draft = {
|
||||
provider: {
|
||||
list: () => Array.fromIterable(editor.providers.values()) as ProviderRecord[],
|
||||
get: (providerID) => editor.providers.get(providerID),
|
||||
list: () => Array.fromIterable(draft.providers.values()) as ProviderRecord[],
|
||||
get: (providerID) => draft.providers.get(providerID),
|
||||
update: (providerID, fn) => {
|
||||
let current = editor.providers.get(providerID)
|
||||
let current = draft.providers.get(providerID)
|
||||
if (!current) {
|
||||
current = {
|
||||
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
|
||||
models: new Map<Model.ID, Model.MutableInfo>(),
|
||||
}
|
||||
editor.providers.set(providerID, current)
|
||||
draft.providers.set(providerID, current)
|
||||
}
|
||||
fn(current.provider)
|
||||
},
|
||||
remove: (providerID) => {
|
||||
editor.providers.delete(providerID)
|
||||
draft.providers.delete(providerID)
|
||||
},
|
||||
},
|
||||
model: {
|
||||
get: (providerID, modelID) => editor.providers.get(providerID)?.models.get(modelID),
|
||||
get: (providerID, modelID) => draft.providers.get(providerID)?.models.get(modelID),
|
||||
update: (providerID, modelID, fn) => {
|
||||
let record = editor.providers.get(providerID)
|
||||
let record = draft.providers.get(providerID)
|
||||
if (!record) {
|
||||
record = {
|
||||
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
|
||||
models: new Map<Model.ID, Model.MutableInfo>(),
|
||||
}
|
||||
editor.providers.set(providerID, record)
|
||||
draft.providers.set(providerID, record)
|
||||
}
|
||||
const model = record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo)
|
||||
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
||||
@@ -123,19 +123,21 @@ const layer = Layer.effect(
|
||||
model.providerID = providerID
|
||||
},
|
||||
remove: (providerID, modelID) => {
|
||||
editor.providers.get(providerID)?.models.delete(modelID)
|
||||
draft.providers.get(providerID)?.models.delete(modelID)
|
||||
},
|
||||
default: {
|
||||
get: () => editor.defaultModel,
|
||||
get: () => draft.defaultModel,
|
||||
set: (providerID, modelID) => {
|
||||
editor.defaultModel = { providerID, modelID }
|
||||
draft.defaultModel = { providerID, modelID }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
return result
|
||||
},
|
||||
notify: () => bus.publish(Catalog.Event.Updated, {}).pipe(Effect.asVoid, Effect.withSpan("Catalog.notify")),
|
||||
finalize: Effect.fn("Catalog.finalize")(function* () {
|
||||
yield* bus.publish(Catalog.Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
const result: Interface = {
|
||||
transform: state.transform,
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface Definition {
|
||||
readonly execute: (input: Invocation) => Effect.Effect<void, unknown>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
add: (definition: Definition) => void
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ export class ExecutionError extends Schema.TaggedError<ExecutionError>()("Comman
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly get: (name: string) => Effect.Effect<Info | undefined>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
readonly execute: (input: {
|
||||
@@ -54,13 +54,13 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const state = State.create<Map<string, Definition>, Editor>({
|
||||
const state = State.create<Map<string, Definition>, Draft>({
|
||||
name: "command",
|
||||
initial: () => new Map(),
|
||||
editor: (editor) => ({
|
||||
add: (definition) => editor.set(definition.name, definition),
|
||||
draft: (draft) => ({
|
||||
add: (definition) => draft.set(definition.name, definition),
|
||||
}),
|
||||
notify: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const info = (definition: Definition) =>
|
||||
Info.make({
|
||||
|
||||
+12
-28
@@ -188,15 +188,7 @@ export const layer = (options?: Options) =>
|
||||
const globalDirectory = AbsolutePath.make(global.config)
|
||||
const globalAgentsDirectory = AbsolutePath.make(path.join(global.home, ".agents"))
|
||||
const globalClaudeDirectory = AbsolutePath.make(path.join(global.home, ".claude"))
|
||||
// Global roots and the walk are compared by canonical path: the same
|
||||
// directory reached under two spellings (a symlinked checkout, macOS
|
||||
// /var vs /private/var, OPENCODE_CONFIG_DIR inside the project) must
|
||||
// classify identically or it enters discovery twice.
|
||||
const globalRoots = yield* Effect.forEach(
|
||||
[globalDirectory, globalClaudeDirectory, globalAgentsDirectory],
|
||||
(item) => fs.resolve(item),
|
||||
)
|
||||
const locationIsGlobal = (yield* fs.resolve(location.directory)) === globalRoots[0]
|
||||
const locationIsGlobal = path.resolve(location.directory) === path.resolve(global.config)
|
||||
const discovered =
|
||||
locationIsGlobal || options?.project === false
|
||||
? []
|
||||
@@ -205,30 +197,22 @@ export const layer = (options?: Options) =>
|
||||
targets: [".opencode", ".claude", ".agents", ...names.toReversed()],
|
||||
start: location.directory,
|
||||
})
|
||||
.pipe(
|
||||
Effect.flatMap((items) =>
|
||||
Effect.forEach(items, (item) =>
|
||||
fs.resolve(item).pipe(Effect.map((resolved) => ({ item, resolved }))),
|
||||
),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const globalEnabled = options?.global !== false
|
||||
// A walked path that resolves into a global root is global config
|
||||
// however the walk reached it (home above the project, or a location
|
||||
// beneath the global config dir), so global: false excludes it
|
||||
// uniformly — classified once here, not per consumer below. With
|
||||
// global enabled, the roots themselves and the global config files are
|
||||
// already loaded below, so the walk must not add them a second time.
|
||||
const globalFiles = yield* Effect.forEach(names, (name) => fs.resolve(path.join(globalDirectory, name)))
|
||||
const visible = discovered
|
||||
.filter(({ resolved }) =>
|
||||
globalEnabled
|
||||
? !globalRoots.includes(resolved) && !globalFiles.includes(resolved)
|
||||
: !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep)),
|
||||
)
|
||||
.map(({ item }) => item)
|
||||
// uniformly — classified once here, not per consumer below.
|
||||
const globalRoots = [globalDirectory, globalClaudeDirectory, globalAgentsDirectory].map((item) =>
|
||||
path.resolve(item),
|
||||
)
|
||||
const visible = globalEnabled
|
||||
? discovered
|
||||
: discovered.filter((item) => {
|
||||
const resolved = path.resolve(item)
|
||||
return !globalRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep))
|
||||
})
|
||||
// We load certain files from a few other folders in the ecosystem
|
||||
const claude = [
|
||||
...new Set([
|
||||
|
||||
@@ -24,7 +24,6 @@ import { ConfigMCPV1 } from "../v1/config/mcp.js"
|
||||
import { ConfigPermissionV1 } from "../v1/config/permission.js"
|
||||
import { ConfigPluginV1 } from "../v1/config/plugin.js"
|
||||
import { ConfigProviderV1 } from "../v1/config/provider.js"
|
||||
import { ConfigV1 } from "../v1/config/config.js"
|
||||
import { ConfigMigrateV1 } from "../v1/config/migrate.js"
|
||||
import { PositiveInt } from "../schema.js"
|
||||
|
||||
@@ -70,9 +69,6 @@ export function normalize(input: unknown): Result {
|
||||
const legacySnapshots = own(input, "snapshot")
|
||||
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
|
||||
: undefined
|
||||
const legacyUpdate = own(input, "autoupdate")
|
||||
? decodeValue(ConfigV1.Info.fields.autoupdate, input.autoupdate, ["autoupdate"], diagnostics)
|
||||
: undefined
|
||||
const legacyShare = own(input, "autoshare")
|
||||
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
|
||||
? "auto"
|
||||
@@ -86,7 +82,6 @@ export function normalize(input: unknown): Result {
|
||||
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
|
||||
}
|
||||
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
|
||||
if (legacyUpdate !== undefined) encoded.update = ConfigMigrateV1.migrate({ autoupdate: legacyUpdate }).update
|
||||
if (legacyShare !== undefined) encoded.share = legacyShare
|
||||
|
||||
const legacyReferences = decodeMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics, decodeEncoded)
|
||||
@@ -196,7 +191,7 @@ export function normalize(input: unknown): Result {
|
||||
shell: Info.fields.shell,
|
||||
model: Info.fields.model,
|
||||
default_agent: Info.fields.default_agent,
|
||||
update: Info.fields.update,
|
||||
autoupdate: Info.fields.autoupdate,
|
||||
share: Info.fields.share,
|
||||
enterprise: Info.fields.enterprise,
|
||||
username: Info.fields.username,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Document, Info, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
|
||||
import path from "path"
|
||||
import { Effect, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
import { ConfigMarkdown } from "../markdown.js"
|
||||
@@ -59,51 +59,42 @@ export const Plugin = define({
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.agent.reload()),
|
||||
)
|
||||
// One trigger feed serializes reloads and shares one debounce window;
|
||||
// subscribing before the initial scan means updates racing the scan still
|
||||
// trigger a rebuild. Each source is subscribed eagerly on its own fiber
|
||||
// (Stream.merge and Stream.debounce both open upstream a fiber hop later)
|
||||
// so no update slips through while the debounce starts its pull.
|
||||
const changes = yield* PubSub.sliding<void>(1)
|
||||
const notify = () => PubSub.publish(changes, undefined)
|
||||
yield* config.changes().pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
|
||||
Stream.runForEach(notify),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(notify),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const updates = yield* PubSub.subscribe(changes)
|
||||
yield* Stream.fromSubscription(updates).pipe(
|
||||
// One merged trigger stream serializes reloads and shares one debounce
|
||||
// window; subscribing before the initial scan means updates racing the
|
||||
// scan still trigger a rebuild.
|
||||
const sourceChanges = config
|
||||
.changes()
|
||||
.pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isAgentSource(entries, update.path))),
|
||||
)
|
||||
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
|
||||
yield* Stream.merge(sourceChanges, configUpdates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.documents = yield* load()
|
||||
yield* ctx.agent.transform((editor) => {
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
const permissions = expandPermissions(
|
||||
loaded.documents.flatMap((document) => document.info.permissions ?? []),
|
||||
global.home,
|
||||
)
|
||||
const configuredDefault = Config.latest(loaded.documents, "default_agent")
|
||||
if (configuredDefault !== undefined) editor.default(Agent.ID.make(configuredDefault))
|
||||
for (const current of editor.list()) {
|
||||
editor.update(current.id, (agent) => agent.permissions.push(...permissions))
|
||||
if (configuredDefault !== undefined) draft.default(Agent.ID.make(configuredDefault))
|
||||
for (const current of draft.list()) {
|
||||
draft.update(current.id, (agent) => agent.permissions.push(...permissions))
|
||||
}
|
||||
|
||||
for (const document of loaded.documents) {
|
||||
for (const [id, item] of Object.entries(document.info.agents ?? {})) {
|
||||
const agentID = Agent.ID.make(id)
|
||||
if (item.disabled) {
|
||||
editor.remove(agentID)
|
||||
draft.remove(agentID)
|
||||
continue
|
||||
}
|
||||
|
||||
const exists = editor.get(agentID) !== undefined
|
||||
editor.update(agentID, (agent) => {
|
||||
const exists = draft.get(agentID) !== undefined
|
||||
draft.update(agentID, (agent) => {
|
||||
if (!exists) agent.permissions.push(...permissions)
|
||||
if (item.model !== undefined)
|
||||
agent.model = {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Model } from "@opencode-ai/schema/model"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import path from "path"
|
||||
import { Effect, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "../../config.js"
|
||||
import { Location } from "../../location.js"
|
||||
@@ -40,34 +40,27 @@ export const Plugin = define({
|
||||
Effect.tap((documents) => Effect.sync(() => (loaded.documents = documents))),
|
||||
Effect.andThen(ctx.command.reload()),
|
||||
)
|
||||
// One trigger feed serializes reloads and shares one debounce window;
|
||||
// subscribing before the initial scan means updates racing the scan still
|
||||
// trigger a rebuild. Each source is subscribed eagerly on its own fiber
|
||||
// (Stream.merge and Stream.debounce both open upstream a fiber hop later)
|
||||
// so no update slips through while the debounce starts its pull.
|
||||
const changes = yield* PubSub.sliding<void>(1)
|
||||
const notify = () => PubSub.publish(changes, undefined)
|
||||
yield* config.changes().pipe(
|
||||
Stream.filterEffect((update) => Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path))),
|
||||
Stream.runForEach(notify),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
Stream.runForEach(notify),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
const updates = yield* PubSub.subscribe(changes)
|
||||
yield* Stream.fromSubscription(updates).pipe(
|
||||
// One merged trigger stream serializes reloads and shares one debounce
|
||||
// window; subscribing before the initial scan means updates racing the
|
||||
// scan still trigger a rebuild.
|
||||
const sourceChanges = config
|
||||
.changes()
|
||||
.pipe(
|
||||
Stream.filterEffect((update) =>
|
||||
Effect.map(config.entries(), (entries) => isCommandSource(entries, update.path)),
|
||||
),
|
||||
)
|
||||
const configUpdates = ctx.event.subscribe().pipe(Stream.filter((event) => event.type === "config.updated"))
|
||||
yield* Stream.merge(sourceChanges, configUpdates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
Stream.runForEach(() => reload),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.documents = yield* load()
|
||||
yield* ctx.command.transform((editor) => {
|
||||
yield* ctx.command.transform((draft) => {
|
||||
for (const document of loaded.documents) {
|
||||
for (const [name, command] of Object.entries(document.commands ?? {})) {
|
||||
editor.add({
|
||||
draft.add({
|
||||
name,
|
||||
description: command.description,
|
||||
execute: (input) =>
|
||||
|
||||
@@ -12,10 +12,10 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, compaction.reload())
|
||||
yield* compaction.transform((editor) => {
|
||||
yield* compaction.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.compaction) continue
|
||||
editor.configure({
|
||||
draft.configure({
|
||||
...(entry.info.compaction.auto === undefined ? {} : { auto: entry.info.compaction.auto }),
|
||||
...(entry.info.compaction.buffer === undefined ? {} : { buffer: entry.info.compaction.buffer }),
|
||||
...(entry.info.compaction.keep?.tokens === undefined ? {} : { tokens: entry.info.compaction.keep.tokens }),
|
||||
|
||||
@@ -24,7 +24,7 @@ export const Plugin = define({
|
||||
const processes = yield* AppProcess.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, formatter.reload())
|
||||
|
||||
yield* formatter.transform((editor) => {
|
||||
yield* formatter.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "formatter")
|
||||
if (!configured) return
|
||||
const builtIns = make({
|
||||
@@ -35,12 +35,12 @@ export const Plugin = define({
|
||||
processes,
|
||||
bin: global.bin,
|
||||
})
|
||||
builtIns.forEach(editor.set)
|
||||
builtIns.forEach(draft.set)
|
||||
if (configured === true) return
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
if (entry.disabled) {
|
||||
editor.remove(name)
|
||||
draft.remove(name)
|
||||
continue
|
||||
}
|
||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||
@@ -51,7 +51,7 @@ export const Plugin = define({
|
||||
enabled:
|
||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
}
|
||||
editor.set(current)
|
||||
draft.set(current)
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -12,12 +12,12 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const image = yield* Image.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, image.reload())
|
||||
yield* image.transform((editor) => {
|
||||
yield* image.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document") continue
|
||||
const configured = entry.info.media?.image
|
||||
if (!configured) continue
|
||||
editor.configure({
|
||||
draft.configure({
|
||||
...(configured.auto_resize === undefined ? {} : { autoResize: configured.auto_resize }),
|
||||
...(configured.max_width === undefined ? {} : { maxWidth: configured.max_width }),
|
||||
...(configured.max_height === undefined ? {} : { maxHeight: configured.max_height }),
|
||||
|
||||
@@ -103,26 +103,22 @@ export const Plugin = define({
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
|
||||
// before debouncing so no update slips through while the debounce starts its pull.
|
||||
const updates = yield* PubSub.subscribe(changes)
|
||||
yield* Stream.fromSubscription(updates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
yield* discovery.transform((editor) => {
|
||||
yield* discovery.transform((draft) => {
|
||||
if (loaded.current.type === "unavailable") {
|
||||
editor.unavailable()
|
||||
draft.unavailable()
|
||||
return
|
||||
}
|
||||
for (const file of loaded.current.files) editor.add(file)
|
||||
for (const file of loaded.current.files) draft.add(file)
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.logWarning("failed to activate instruction source", { cause }).pipe(
|
||||
Effect.andThen(discovery.transform((editor) => editor.unavailable())),
|
||||
Effect.andThen(discovery.transform((draft) => draft.unavailable())),
|
||||
Effect.asVoid,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,10 +12,10 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, policy.reload())
|
||||
yield* policy.transform((editor) => {
|
||||
yield* policy.transform((draft) => {
|
||||
for (const entry of loaded.entries) {
|
||||
if (entry.type !== "document" || !entry.info.watcher?.ignore) continue
|
||||
editor.add(entry.info.watcher.ignore)
|
||||
draft.add(entry.info.watcher.ignore)
|
||||
}
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -36,7 +36,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
|
||||
// Subscribe before the initial load so updates racing it trigger a rebuild.
|
||||
loaded.entries = yield* config.entries()
|
||||
yield* mcp.transform((editor) => {
|
||||
yield* mcp.transform((draft) => {
|
||||
const documents = loaded.entries.filter((entry): entry is Document => entry.type === "document")
|
||||
// Global timeout defaults merge in config order; each server can override them.
|
||||
const timeout = Object.assign(
|
||||
@@ -50,8 +50,8 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
|
||||
}
|
||||
}
|
||||
for (const [name, server] of servers) {
|
||||
if (editor.get(name)) continue
|
||||
editor.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
if (draft.get(name)) continue
|
||||
draft.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,14 +19,14 @@ export const Plugin = define({
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, ctx.reference.reload())
|
||||
yield* ctx.reference.transform((editor) => {
|
||||
yield* ctx.reference.transform((draft) => {
|
||||
for (const doc of loaded.entries.filter((entry): entry is Document => entry.type === "document")) {
|
||||
const directory = doc.path ? path.dirname(doc.path) : location.directory
|
||||
for (const [name, entry] of Object.entries(doc.info.references ?? {})) {
|
||||
if (!validAlias(name)) continue
|
||||
const description = typeof entry === "string" ? undefined : entry.description
|
||||
const hidden = typeof entry === "string" ? undefined : entry.hidden
|
||||
editor.add(
|
||||
draft.add(
|
||||
name,
|
||||
local(entry)
|
||||
? Reference.LocalSource.make({
|
||||
|
||||
@@ -12,9 +12,9 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const shell = yield* ShellSelect.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, shell.reload())
|
||||
yield* shell.transform((editor) => {
|
||||
yield* shell.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "shell")
|
||||
if (configured) editor.configure(configured)
|
||||
if (configured) draft.configure(configured)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -171,17 +171,13 @@ export const Plugin = define({
|
||||
(effect, ..._args: [file?: string]) => lock.withPermit(effect),
|
||||
)
|
||||
|
||||
// Editor saves arrive as bursts of watcher events; settle before rescanning once. Subscribe
|
||||
// before debouncing so no update slips through while the debounce starts its pull.
|
||||
const updates = yield* PubSub.subscribe(changes)
|
||||
yield* Stream.fromSubscription(updates).pipe(
|
||||
Stream.debounce("100 millis"),
|
||||
yield* Stream.fromPubSub(changes).pipe(
|
||||
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(ctx.skill.reload()))),
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
yield* refresh()
|
||||
yield* ctx.skill.transform((editor) => {
|
||||
for (const skill of loaded.skills) editor.add(skill)
|
||||
yield* ctx.skill.transform((draft) => {
|
||||
for (const skill of loaded.skills) draft.add(skill)
|
||||
})
|
||||
yield* ctx.event.subscribe().pipe(
|
||||
Stream.filter((event) => event.type === "config.updated"),
|
||||
|
||||
@@ -12,10 +12,10 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, snapshot.reload())
|
||||
yield* snapshot.transform((editor) => {
|
||||
yield* snapshot.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "snapshots")
|
||||
if (configured === undefined) return
|
||||
editor.configure(configured)
|
||||
draft.configure(configured)
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -3,11 +3,10 @@ export * as ConfigPluginSource from "./source.js"
|
||||
import { Directory, Document, type Entry } from "@opencode-ai/schema/config"
|
||||
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Host } from "@opencode-ai/plugin/host"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Option, PubSub, Scope, Stream } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath, pathToFileURL } from "url"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Config } from "../../config.js"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { Location } from "../../location.js"
|
||||
@@ -42,8 +41,8 @@ export const layer = Layer.effect(
|
||||
const configuredChanges = yield* PubSub.unbounded<void>()
|
||||
const watched = new Set<string>()
|
||||
|
||||
// Configured local plugins can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those targets directly.
|
||||
// Configured local plugin entrypoints can live outside config roots, where the
|
||||
// config change feed cannot see them; watch those entrypoints directly.
|
||||
// Watches start on first sighting and are never torn down individually:
|
||||
// a stale watch after a config edit costs one deduped fs handle and a
|
||||
// no-op activation, and every watch dies with this layer's scope.
|
||||
@@ -57,10 +56,7 @@ export const layer = Layer.effect(
|
||||
// The config change feed already covers {plugin,plugins} directories.
|
||||
if (isPluginSource(entries, operation.target)) continue
|
||||
watched.add(operation.target)
|
||||
const updates = yield* watcher.subscribe({
|
||||
path: operation.target,
|
||||
type: (yield* fs.isDir(operation.target)) ? "directory" : "file",
|
||||
})
|
||||
const updates = yield* watcher.subscribe({ path: operation.target, type: "file" })
|
||||
yield* updates.pipe(
|
||||
Stream.runForEach(() => PubSub.publish(configuredChanges, undefined)),
|
||||
Effect.catchCause((cause) =>
|
||||
@@ -152,40 +148,24 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
|
||||
yield* Effect.logWarning("configured plugin path must be a directory", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
}
|
||||
return Option.some<Operation>(operation)
|
||||
if (!(yield* fs.isDir(operation.target))) return Option.some<Operation>(operation)
|
||||
const entrypoint = yield* PluginSourceDirectory.entrypoint(fs, operation.target)
|
||||
if (Option.isSome(entrypoint)) return Option.some<Operation>({ ...operation, target: entrypoint.value })
|
||||
yield* Effect.logWarning("configured plugin directory has no index entrypoint", { target: operation.target })
|
||||
return Option.none<Operation>()
|
||||
}),
|
||||
).pipe(Effect.map((operations) => operations.flatMap(Option.toArray)))
|
||||
// Explicit config is applied last so it can remove auto-discovered packages.
|
||||
return yield* Effect.forEach([...discovered, ...resolved], (operation) =>
|
||||
Effect.gen(function* () {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return [operation]
|
||||
if (!(yield* fs.existsSafe(operation.target))) return [operation]
|
||||
const directory = yield* fs.isDir(operation.target)
|
||||
const entrypoints: Host.Entrypoints = directory
|
||||
? yield* Effect.sync(() => Host.resolve({ directory: operation.target }))
|
||||
: { server: pathToFileURL(operation.target).href }
|
||||
if (!entrypoints.server) return []
|
||||
if (directory) {
|
||||
const root = yield* fs.resolve(operation.target)
|
||||
const server = yield* fs.resolve(fileURLToPath(entrypoints.server))
|
||||
if (!FSUtil.contains(root, server)) return []
|
||||
}
|
||||
const times = yield* Effect.forEach(
|
||||
[
|
||||
...Object.values(entrypoints)
|
||||
.filter((entry) => entry !== undefined)
|
||||
.map((entry) => fileURLToPath(entry)),
|
||||
path.join(directory ? operation.target : path.dirname(operation.target), "package.json"),
|
||||
],
|
||||
(entry) =>
|
||||
fs.stat(entry).pipe(
|
||||
Effect.map((info) => Option.getOrElse(info.mtime, () => new Date(0)).getTime()),
|
||||
Effect.orElseSucceed(() => 0),
|
||||
),
|
||||
)
|
||||
return [{ ...operation, mtime: Math.max(...times) }]
|
||||
}),
|
||||
).pipe(Effect.map((operations) => operations.flat()))
|
||||
return yield* Effect.forEach([...discovered, ...resolved], (operation) => {
|
||||
if (operation.type === "remove" || !path.isAbsolute(operation.target)) return Effect.succeed(operation)
|
||||
return fs.stat(operation.target).pipe(
|
||||
Effect.map((info) => ({
|
||||
...operation,
|
||||
mtime: Option.getOrElse(info.mtime, () => new Date(0)).getTime(),
|
||||
})),
|
||||
Effect.orElseSucceed(() => operation),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
function isPluginSource(entries: readonly Entry[], file: string) {
|
||||
|
||||
@@ -12,10 +12,10 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const output = yield* ToolOutput.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, output.reload())
|
||||
yield* output.transform((editor) => {
|
||||
yield* output.transform((draft) => {
|
||||
const configured = Config.latest(loaded.entries, "tool_output")
|
||||
if (!configured) return
|
||||
editor.configure({
|
||||
draft.configure({
|
||||
...(configured.max_lines === undefined ? {} : { maxLines: configured.max_lines }),
|
||||
...(configured.max_bytes === undefined ? {} : { maxBytes: configured.max_bytes }),
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ export * as Database from "./database.js"
|
||||
|
||||
import { EffectDrizzleSqlite } from "./drizzle.js"
|
||||
import { sqliteLayer, supportsForeignKeyToggle, supportsTuningPragmas } from "#sqlite"
|
||||
import { Context, Effect, Layer, Schema, Semaphore } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import type { SqlClient } from "effect/unstable/sql"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { isAbsolute, join } from "path"
|
||||
@@ -23,52 +23,30 @@ export type Options = typeof Options.Type
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/storage/Database") {}
|
||||
|
||||
// The bootstrap lock is scoped to the database being built, never to this
|
||||
// module: on workerd every Durable Object in an isolate shares module state, and
|
||||
// releasing a shared semaphore resumes the waiting object's fiber inside the
|
||||
// releasing object's I/O context, where its first storage call is rejected as
|
||||
// cross-object I/O.
|
||||
const databaseLayer = (lock: Effect.Effect<Semaphore.Semaphore>) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
const databaseLayer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = yield* makeDatabase
|
||||
|
||||
if (supportsTuningPragmas) {
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
}
|
||||
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
|
||||
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
|
||||
const semaphore = yield* lock
|
||||
yield* semaphore.withPermit(DatabaseMigration.apply(db))
|
||||
if (supportsTuningPragmas) {
|
||||
yield* db.run("PRAGMA journal_mode = WAL")
|
||||
yield* db.run("PRAGMA synchronous = NORMAL")
|
||||
yield* db.run("PRAGMA busy_timeout = 5000")
|
||||
yield* db.run("PRAGMA cache_size = -64000")
|
||||
yield* db.run("PRAGMA wal_checkpoint(PASSIVE)")
|
||||
}
|
||||
// Durable Object SQLite always enforces foreign keys and rejects the pragma.
|
||||
if (supportsForeignKeyToggle) yield* db.run("PRAGMA foreign_keys = ON")
|
||||
yield* DatabaseMigration.apply(db)
|
||||
|
||||
return { db }
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
// Two instances over one file bootstrap the same schema, so file databases
|
||||
// share a lock per path. Each in-memory database is its own connection.
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
function lockFor(filename: string) {
|
||||
const existing = locks.get(filename)
|
||||
if (existing) return existing
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
locks.set(filename, lock)
|
||||
return lock
|
||||
}
|
||||
return { db }
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
|
||||
export function layer(options: Options = { path: ":memory:" }) {
|
||||
return Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const provide = (filename: string) =>
|
||||
databaseLayer(filename === ":memory:" ? Semaphore.make(1) : Effect.succeed(lockFor(filename))).pipe(
|
||||
Layer.provide(sqliteLayer({ filename })),
|
||||
)
|
||||
const provide = (filename: string) => layerFromClient.pipe(Layer.provide(sqliteLayer({ filename })))
|
||||
const filename = options.path ?? ":memory:"
|
||||
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
|
||||
const global = yield* Global.Service
|
||||
@@ -80,11 +58,8 @@ export function layer(options: Options = { path: ":memory:" }) {
|
||||
// The database service over an injected SqlClient, for runtimes that receive
|
||||
// database storage instead of opening a filesystem path. Any client provided
|
||||
// here still goes through the pragma guards and migrations; Global is required
|
||||
// because migrations may read it (the v1 import). The lock is created per build
|
||||
// because every Durable Object builds this layer over its own storage.
|
||||
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer(
|
||||
Semaphore.make(1),
|
||||
)
|
||||
// because migrations may read it (the v1 import).
|
||||
export const layerFromClient: Layer.Layer<Service, never, SqlClient.SqlClient | Global.Service> = databaseLayer
|
||||
|
||||
export function configured(options?: Options) {
|
||||
return makeGlobalNode({ service: Service, layer: layer(options), deps: [Global.node] })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as DatabaseMigration from "./migration.js"
|
||||
|
||||
import { sql } from "drizzle-orm"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Semaphore } from "effect"
|
||||
import { supportsForeignKeyToggle } from "#sqlite"
|
||||
import type { EffectDrizzleSqlite } from "./drizzle.js"
|
||||
import { migrations } from "./migration.gen.js"
|
||||
@@ -10,6 +10,7 @@ import { Global } from "@opencode-ai/util/global"
|
||||
|
||||
type Database = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
|
||||
export type Migration = {
|
||||
id: string
|
||||
@@ -17,38 +18,38 @@ export type Migration = {
|
||||
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
|
||||
}
|
||||
|
||||
// Not serialized here: the Database layer holds a lock scoped to the database
|
||||
// it is bootstrapping, since two instances over one file must not race.
|
||||
export function apply(db: Database) {
|
||||
return Effect.gen(function* () {
|
||||
// OpenCode owns the unprefixed table namespace. Embedders sharing this
|
||||
// database may own underscore-prefixed tables, which bootstrap ignores.
|
||||
const tables = yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
|
||||
const started = Date.now()
|
||||
yield* Effect.logInfo("database schema bootstrap started", { migrations: migrations.length })
|
||||
yield* db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* schema.up(tx)
|
||||
yield* tx.run(
|
||||
sql`CREATE TABLE ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
|
||||
)
|
||||
yield* Effect.forEach(migrations, (migration) =>
|
||||
tx.run(
|
||||
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("database schema bootstrap completed", {
|
||||
migrations: migrations.length,
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
})
|
||||
return lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
// OpenCode owns the unprefixed table namespace. Embedders sharing this
|
||||
// database may own underscore-prefixed tables, which bootstrap ignores.
|
||||
const tables = yield* db.all<{ name: string }>(
|
||||
sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND substr(name, 1, 1) <> '_'`,
|
||||
)
|
||||
if (tables.some((table) => table.name === "session" || table.name === "session_v2"))
|
||||
return yield* applyOnly(db, migrations)
|
||||
if (tables.length > 0) return yield* Effect.die(new Error("Database is not empty and has no session table"))
|
||||
const started = Date.now()
|
||||
yield* Effect.logInfo("database schema bootstrap started", { migrations: migrations.length })
|
||||
yield* db.transaction((tx) =>
|
||||
Effect.gen(function* () {
|
||||
yield* schema.up(tx)
|
||||
yield* tx.run(
|
||||
sql`CREATE TABLE ${sql.identifier("migration")} (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`,
|
||||
)
|
||||
yield* Effect.forEach(migrations, (migration) =>
|
||||
tx.run(
|
||||
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
yield* Effect.logInfo("database schema bootstrap completed", {
|
||||
migrations: migrations.length,
|
||||
durationMs: Date.now() - started,
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function applyOnly(db: Database, input: Migration[]) {
|
||||
|
||||
@@ -8,12 +8,12 @@ type Data = {
|
||||
ignore: string[]
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
add: (ignore: readonly string[]) => void
|
||||
list: () => readonly string[]
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly current: () => readonly string[]
|
||||
readonly observe: (
|
||||
listener: (ignore: readonly string[]) => Effect.Effect<void>,
|
||||
@@ -25,19 +25,20 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
let current: readonly string[] = []
|
||||
const listeners = new Set<(ignore: readonly string[]) => Effect.Effect<void>>()
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "location-watcher-policy",
|
||||
initial: () => ({ ignore: [] }),
|
||||
editor: (editor) => ({
|
||||
add: (ignore) => editor.ignore.push(...ignore),
|
||||
list: () => editor.ignore,
|
||||
draft: (draft) => ({
|
||||
add: (ignore) => draft.ignore.push(...ignore),
|
||||
list: () => draft.ignore,
|
||||
}),
|
||||
// Read per listener: a reentrant transform inside an earlier listener must reach later ones.
|
||||
notify: () => Effect.forEach(listeners, (listener) => listener(current()), { discard: true }),
|
||||
finalize: (draft) =>
|
||||
Effect.sync(() => {
|
||||
current = [...draft.list()]
|
||||
}).pipe(Effect.andThen(Effect.forEach(listeners, (listener) => listener(current), { discard: true }))),
|
||||
})
|
||||
// Annotated to break the inference cycle through notify: notify reads current, current reads state.
|
||||
const current = (): readonly string[] => state.get().ignore
|
||||
const observe = Effect.fn("LocationWatcherPolicy.observe")(function* (
|
||||
listener: (ignore: readonly string[]) => Effect.Effect<void>,
|
||||
) {
|
||||
@@ -55,7 +56,7 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
current,
|
||||
current: () => current,
|
||||
observe,
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -24,6 +24,7 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const git = yield* Git.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const policy = yield* LocationWatcherPolicy.Service
|
||||
const publish = (update: { type: "create" | "update" | "delete"; path: string }) =>
|
||||
bus.publish(FileSystem.Event.Changed, {
|
||||
@@ -92,7 +93,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
yield* policy.observe(reconcile)
|
||||
yield* Effect.gen(function* () {
|
||||
yield* Plugin.awaitActivation
|
||||
yield* plugins.awaitActivation
|
||||
yield* reconcile(policy.current())
|
||||
}).pipe(
|
||||
Effect.catchCauseIf(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Cause, Context, Effect, Layer, PubSub, RcMap, Schema, Stream } from "effect"
|
||||
import { lazy } from "../util/lazy.js"
|
||||
import { watch } from "node:fs"
|
||||
@@ -71,7 +70,7 @@ export type Options = typeof Options.Type
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Watcher") {}
|
||||
|
||||
export interface TestInterface extends Interface {
|
||||
/** Delivers one update to every active watch whose target covers `update.path`. */
|
||||
/** Broadcasts one update to every subscriber. */
|
||||
readonly emit: (update: Update) => Effect.Effect<void>
|
||||
/** Returns every subscribe call observed so far, in order. */
|
||||
readonly subscriptions: () => Effect.Effect<readonly WatchInput[]>
|
||||
@@ -151,14 +150,12 @@ export const layer = (options?: Options) =>
|
||||
|
||||
/**
|
||||
* Watcher for tests: the real lifecycle over an in-memory Native that records
|
||||
* acquired watches and routes emitted updates the way the OS watches would: a
|
||||
* file watch receives updates for its own path, a directory watch receives
|
||||
* updates for paths inside it that no ignore entry covers.
|
||||
* acquired watches and broadcasts emitted updates to every active watch.
|
||||
*/
|
||||
export const testLayer = Layer.effectContext(
|
||||
Effect.gen(function* () {
|
||||
const subscriptions: WatchInput[] = []
|
||||
const active = new Map<(update: Update) => void, (path: string) => boolean>()
|
||||
const active = new Set<(update: Update) => void>()
|
||||
const native = Native.of({
|
||||
subscribe: (input) =>
|
||||
Effect.sync(() => {
|
||||
@@ -169,17 +166,7 @@ export const testLayer = Layer.effectContext(
|
||||
? { path: input.target, type: "directory", ignore: input.ignore }
|
||||
: { path: input.target, type: "directory" },
|
||||
)
|
||||
// Ignore entries resolve against the target like the parcel wrapper's
|
||||
// literal paths. Glob entries resolve to paths nothing lives under, so
|
||||
// they are inert here rather than compiled the way parcel compiles them.
|
||||
const ignored = input.ignore.map((entry) => path.resolve(input.target, entry))
|
||||
active.set(
|
||||
input.publish,
|
||||
input.type === "file"
|
||||
? (target) => target === input.target
|
||||
: (target) =>
|
||||
FSUtil.contains(input.target, target) && !ignored.some((entry) => FSUtil.contains(entry, target)),
|
||||
)
|
||||
active.add(input.publish)
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
active.delete(input.publish)
|
||||
@@ -191,13 +178,7 @@ export const testLayer = Layer.effectContext(
|
||||
const context = yield* Layer.build(layer().pipe(Layer.provide(Layer.succeed(Native, native))))
|
||||
const test = Test.of({
|
||||
subscribe: Context.get(context, Service).subscribe,
|
||||
emit: (update) =>
|
||||
Effect.sync(() => {
|
||||
const target = path.resolve(update.path)
|
||||
active.forEach((matches, publish) => {
|
||||
if (matches(target)) publish(update)
|
||||
})
|
||||
}),
|
||||
emit: (update) => Effect.sync(() => active.forEach((publish) => publish(update))),
|
||||
subscriptions: () => Effect.sync(() => [...subscriptions]),
|
||||
})
|
||||
return Context.empty().pipe(Context.add(Service, test), Context.add(Test, test))
|
||||
|
||||
@@ -13,12 +13,12 @@ type Data = {
|
||||
formatters: Info[]
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
set: (formatter: Info) => void
|
||||
remove: (name: string) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
@@ -30,17 +30,17 @@ const layer = Layer.effect(
|
||||
const location = yield* Location.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const commands = new WeakMap<Info, string[] | false>()
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "formatter",
|
||||
initial: () => ({ formatters: [] }),
|
||||
editor: (editor) => ({
|
||||
draft: (draft) => ({
|
||||
set: (formatter) => {
|
||||
const index = editor.formatters.findIndex((item) => item.name === formatter.name)
|
||||
if (index === -1) editor.formatters.push(formatter)
|
||||
else editor.formatters[index] = formatter
|
||||
const index = draft.formatters.findIndex((item) => item.name === formatter.name)
|
||||
if (index === -1) draft.formatters.push(formatter)
|
||||
else draft.formatters[index] = formatter
|
||||
},
|
||||
remove: (name) => {
|
||||
editor.formatters = editor.formatters.filter((formatter) => formatter.name !== name)
|
||||
draft.formatters = draft.formatters.filter((formatter) => formatter.name !== name)
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -39,11 +39,11 @@ export type Limits = {
|
||||
maxBase64Bytes: number
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
configure: (limits: Partial<Limits>) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly normalize: (
|
||||
resource: string,
|
||||
content: FileSystem.Content & { readonly encoding: "base64" },
|
||||
@@ -58,7 +58,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Im
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const state = State.create<Limits, Editor>({
|
||||
const state = State.create<Limits, Draft>({
|
||||
name: "image",
|
||||
initial: () => ({
|
||||
autoResize: true,
|
||||
@@ -66,12 +66,12 @@ const layer = Layer.effect(
|
||||
maxHeight: 2_000,
|
||||
maxBase64Bytes: 5 * 1024 * 1024,
|
||||
}),
|
||||
editor: (editor) => ({
|
||||
draft: (draft) => ({
|
||||
configure: (limits) => {
|
||||
if (limits.autoResize !== undefined) editor.autoResize = limits.autoResize
|
||||
if (limits.maxWidth !== undefined) editor.maxWidth = limits.maxWidth
|
||||
if (limits.maxHeight !== undefined) editor.maxHeight = limits.maxHeight
|
||||
if (limits.maxBase64Bytes !== undefined) editor.maxBase64Bytes = limits.maxBase64Bytes
|
||||
if (limits.autoResize !== undefined) draft.autoResize = limits.autoResize
|
||||
if (limits.maxWidth !== undefined) draft.maxWidth = limits.maxWidth
|
||||
if (limits.maxHeight !== undefined) draft.maxHeight = limits.maxHeight
|
||||
if (limits.maxBase64Bytes !== undefined) draft.maxBase64Bytes = limits.maxBase64Bytes
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -25,7 +25,7 @@ export type Data = {
|
||||
available: boolean
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Types.DeepMutable<File>[]
|
||||
// Map insertion order is render order: config adds global then nearest-to-farthest project files;
|
||||
// sibling contributors interleave by transform registration order.
|
||||
@@ -35,7 +35,7 @@ export type Editor = {
|
||||
unavailable: () => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
// Discovery policy lives here because internal plugins have no per-composition options channel.
|
||||
// Move it into plugin config once plugins can consume their own options.
|
||||
readonly project: boolean
|
||||
@@ -57,24 +57,24 @@ export const layer = (options?: Options) =>
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "instruction-discovery",
|
||||
initial: () => ({ files: new Map(), available: true }),
|
||||
editor: (editor) => ({
|
||||
list: () => Array.from(editor.files.values()),
|
||||
add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable<File>),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.files.values()),
|
||||
add: (file) => draft.files.set(file.path, new File(file) as Types.DeepMutable<File>),
|
||||
update: (path, update) => {
|
||||
const current = editor.files.get(AbsolutePath.make(path))
|
||||
const current = draft.files.get(AbsolutePath.make(path))
|
||||
if (!current) return
|
||||
update(current)
|
||||
current.path = AbsolutePath.make(path)
|
||||
},
|
||||
remove: (path) => editor.files.delete(AbsolutePath.make(path)),
|
||||
remove: (path) => draft.files.delete(AbsolutePath.make(path)),
|
||||
unavailable: () => {
|
||||
editor.available = false
|
||||
draft.available = false
|
||||
},
|
||||
}),
|
||||
notify: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const source = (value: ReadonlyArray<File> | Instructions.Unavailable | Instructions.Removed) =>
|
||||
|
||||
@@ -130,7 +130,7 @@ type Data = {
|
||||
integrations: Map<ID, Entry>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly Ref[]
|
||||
get: (id: ID) => Ref | undefined
|
||||
update: (id: ID, update: (integration: Types.DeepMutable<Ref>) => void) => void
|
||||
@@ -142,7 +142,7 @@ export type Editor = {
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
/** Registers a scoped transform over the integration registry. */
|
||||
/** Returns one integration with its methods and current connections. */
|
||||
readonly get: (id: ID) => Effect.Effect<Info | undefined>
|
||||
@@ -266,27 +266,27 @@ const layer = Layer.effect(
|
||||
const scope = yield* Scope.Scope
|
||||
const attempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, AttemptEntry>())
|
||||
const commandAttempts = SynchronizedRef.makeUnsafe(new Map<AttemptID, CommandAttemptEntry>())
|
||||
const state = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "integration",
|
||||
initial: () => ({ integrations: new Map<ID, Entry>() }),
|
||||
editor: (editor) => ({
|
||||
list: () => Array.from(editor.integrations.values(), (entry) => entry.ref) as Ref[],
|
||||
get: (id) => editor.integrations.get(id)?.ref as Ref | undefined,
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.integrations.values(), (entry) => entry.ref) as Ref[],
|
||||
get: (id) => draft.integrations.get(id)?.ref as Ref | undefined,
|
||||
update: (id, update) => {
|
||||
const current = editor.integrations.get(id) ?? {
|
||||
const current = draft.integrations.get(id) ?? {
|
||||
ref: { id, name: id },
|
||||
methods: [],
|
||||
implementations: new Map(),
|
||||
}
|
||||
if (!editor.integrations.has(id)) editor.integrations.set(id, current)
|
||||
if (!draft.integrations.has(id)) draft.integrations.set(id, current)
|
||||
update(current.ref)
|
||||
current.ref.id = id
|
||||
},
|
||||
remove: (id) => editor.integrations.delete(id),
|
||||
remove: (id) => draft.integrations.delete(id),
|
||||
method: {
|
||||
list: (integrationID) => (editor.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
|
||||
list: (integrationID) => (draft.integrations.get(integrationID)?.methods as Method[] | undefined) ?? [],
|
||||
update: (implementation) => {
|
||||
const current = editor.integrations.get(implementation.integrationID) ?? {
|
||||
const current = draft.integrations.get(implementation.integrationID) ?? {
|
||||
ref: {
|
||||
id: implementation.integrationID,
|
||||
name: implementation.integrationID,
|
||||
@@ -294,8 +294,8 @@ const layer = Layer.effect(
|
||||
methods: [],
|
||||
implementations: new Map<MethodID, Types.DeepMutable<OAuthImplementation>>(),
|
||||
}
|
||||
if (!editor.integrations.has(implementation.integrationID)) {
|
||||
editor.integrations.set(implementation.integrationID, current)
|
||||
if (!draft.integrations.has(implementation.integrationID)) {
|
||||
draft.integrations.set(implementation.integrationID, current)
|
||||
}
|
||||
const index = current.methods.findIndex((method) => {
|
||||
if (method.type !== implementation.method.type) return false
|
||||
@@ -315,7 +315,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
},
|
||||
remove: (integrationID, method) => {
|
||||
const current = editor.integrations.get(integrationID)
|
||||
const current = draft.integrations.get(integrationID)
|
||||
if (!current) return
|
||||
const index = current.methods.findIndex((candidate) => {
|
||||
if (candidate.type !== method.type) return false
|
||||
@@ -328,7 +328,7 @@ const layer = Layer.effect(
|
||||
},
|
||||
},
|
||||
}),
|
||||
notify: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Integration.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
|
||||
const createCredential = Effect.fnUntraced(function* (input: Parameters<Credential.Interface["create"]>[0]) {
|
||||
@@ -400,17 +400,21 @@ const layer = Layer.effect(
|
||||
}
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const persistence = yield* Effect.suspend(() => {
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
return createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label: attempt.label ?? implementation?.label?.(exit.value),
|
||||
value: exit.value,
|
||||
})
|
||||
}).pipe(Effect.asVoid, Effect.exit)
|
||||
const implementation = state
|
||||
.get()
|
||||
.integrations.get(attempt.integrationID)
|
||||
?.implementations.get(attempt.methodID)
|
||||
const persistence = yield* Effect.sync(() => attempt.label ?? implementation?.label?.(exit.value)).pipe(
|
||||
Effect.flatMap((label) =>
|
||||
createCredential({
|
||||
integrationID: attempt.integrationID,
|
||||
label,
|
||||
value: exit.value,
|
||||
}),
|
||||
),
|
||||
Effect.asVoid,
|
||||
Effect.exit,
|
||||
)
|
||||
const settledAt = yield* Clock.currentTimeMillis
|
||||
const terminal: TerminalAttempt = Exit.isSuccess(persistence)
|
||||
? {
|
||||
@@ -428,7 +432,7 @@ const layer = Layer.effect(
|
||||
}
|
||||
// Persisting attempts cannot be cancelled, expired, or claimed again.
|
||||
yield* SynchronizedRef.update(attempts, (current) => new Map(current).set(attemptID, terminal))
|
||||
yield* persistence
|
||||
if (Exit.isFailure(persistence)) yield* Effect.failCause(persistence.cause)
|
||||
}).pipe(Effect.ensuring(close(attempt.scope)))
|
||||
}, Effect.uninterruptible)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ephemeral } from "@opencode-ai/schema/event"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
import { createHash } from "node:crypto"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Semaphore, Stream, Types } from "effect"
|
||||
import { Cause, Context, Effect, Exit, FiberSet, Latch, Layer, Schema, Scope, Stream, Types } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Credential } from "../credential.js"
|
||||
import { Bus } from "../bus.js"
|
||||
@@ -133,7 +133,7 @@ type Data = {
|
||||
removed: Set<ServerName>
|
||||
}
|
||||
|
||||
export type Editor = {
|
||||
export type Draft = {
|
||||
list: () => readonly [ServerName, Types.DeepMutable<Mcp.ServerConfig>][]
|
||||
get: (server: ServerName | string) => Types.DeepMutable<Mcp.ServerConfig> | undefined
|
||||
set: (server: ServerName | string, config: Mcp.ServerConfig) => void
|
||||
@@ -143,7 +143,7 @@ export type Editor = {
|
||||
|
||||
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||
readonly add: (server: ServerName | string, config: Mcp.ServerConfig) => Effect.Effect<void>
|
||||
readonly connect: (server: ServerName | string) => Effect.Effect<void, NotFoundError>
|
||||
@@ -227,12 +227,12 @@ export const layer = (options?: Options) =>
|
||||
const scope = yield* Scope.fork(root)
|
||||
entry.registration = { dispose: Scope.close(scope, Exit.void) }
|
||||
yield* integration
|
||||
.transform((editor) => {
|
||||
editor.update(integrationID, (ref) => {
|
||||
.transform((draft) => {
|
||||
draft.update(integrationID, (ref) => {
|
||||
ref.name = name
|
||||
ref.metadata = { source: "mcp" }
|
||||
})
|
||||
editor.method.update({
|
||||
draft.method.update({
|
||||
integrationID,
|
||||
method: { id: methodID, type: "oauth", label: name },
|
||||
authorize: () =>
|
||||
@@ -615,9 +615,8 @@ export const layer = (options?: Options) =>
|
||||
|
||||
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
|
||||
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
|
||||
const reconcileLock = Semaphore.makeUnsafe(1)
|
||||
const reconcile = Effect.fnUntraced(function* () {
|
||||
const servers = state.get().servers
|
||||
const reconcile = Effect.fnUntraced(function* (next: Draft) {
|
||||
const servers = new Map(next.list())
|
||||
if (!applied && entries.size === 0) {
|
||||
for (const [name, server] of servers) {
|
||||
entries.set(name, {
|
||||
@@ -678,7 +677,7 @@ export const layer = (options?: Options) =>
|
||||
Stream.runForEach((event) => Effect.sync(() => fork(reconnect(event.data.integrationID)))),
|
||||
),
|
||||
)
|
||||
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "mcp",
|
||||
initial: () => ({
|
||||
servers: new Map(
|
||||
@@ -688,22 +687,22 @@ export const layer = (options?: Options) =>
|
||||
),
|
||||
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
|
||||
}),
|
||||
editor: (editor) => ({
|
||||
list: () => Array.from(editor.servers),
|
||||
get: (server) => editor.servers.get(ServerName.make(server)),
|
||||
draft: (draft) => ({
|
||||
list: () => Array.from(draft.servers),
|
||||
get: (server) => draft.servers.get(ServerName.make(server)),
|
||||
set: (server, serverConfig) => {
|
||||
const name = ServerName.make(server)
|
||||
if (editor.removed.has(name)) return
|
||||
editor.servers.set(name, cloneConfig(serverConfig))
|
||||
if (draft.removed.has(name)) return
|
||||
draft.servers.set(name, cloneConfig(serverConfig))
|
||||
},
|
||||
update: (server, update) => {
|
||||
const current = editor.servers.get(ServerName.make(server))
|
||||
const current = draft.servers.get(ServerName.make(server))
|
||||
if (!current) return
|
||||
update(current)
|
||||
},
|
||||
remove: (server) => editor.servers.delete(ServerName.make(server)),
|
||||
remove: (server) => draft.servers.delete(ServerName.make(server)),
|
||||
}),
|
||||
notify: () => State.reconcile(root, fork, () => reconcileLock.withPermit(reconcile())),
|
||||
finalize: reconcile,
|
||||
})
|
||||
|
||||
// Suspend so each await sees current entries; a bare Map iterator is exhausted after one run.
|
||||
|
||||
@@ -3,7 +3,8 @@ export * as ModelResolver from "./model-resolver.js"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LanguageModel } from "@opencode-ai/ai"
|
||||
import { Auth } from "@opencode-ai/ai/route"
|
||||
import { Context, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { produce } from "immer"
|
||||
import { AISDK } from "./aisdk.js"
|
||||
import { AISDKNative } from "./aisdk-native.js"
|
||||
import { Catalog } from "./catalog.js"
|
||||
@@ -78,8 +79,6 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelResolver") {}
|
||||
|
||||
// Catalog models are shared with the retained registry value and stay editable by later transforms, so
|
||||
// resolution copies with spreads: immer's produce would deep-freeze the unchanged subtrees it shares with its input.
|
||||
export const withVariant = (
|
||||
model: Info,
|
||||
variantID: VariantID | undefined,
|
||||
@@ -96,12 +95,11 @@ export const withVariant = (
|
||||
)
|
||||
return Effect.succeed(
|
||||
variant
|
||||
? {
|
||||
...model,
|
||||
settings: Provider.mergeOverlay(model.settings, variant.settings),
|
||||
headers: Provider.mergeHeaders(model.headers, variant.headers),
|
||||
body: Provider.mergeOverlay(model.body, variant.body),
|
||||
}
|
||||
? produce(model, (draft) => {
|
||||
draft.settings = Provider.mergeOverlay(draft.settings, variant.settings)
|
||||
draft.headers = Provider.mergeHeaders(draft.headers, variant.headers)
|
||||
draft.body = Provider.mergeOverlay(draft.body, variant.body)
|
||||
})
|
||||
: model,
|
||||
)
|
||||
}
|
||||
@@ -149,7 +147,10 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
...configuration,
|
||||
}) ?? {},
|
||||
)
|
||||
return yield* loadAISDK({ ...resolved, settings }).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
const runtime = produce(resolved, (draft) => {
|
||||
draft.settings = settings
|
||||
})
|
||||
return yield* loadAISDK(runtime).pipe(Effect.mapError(() => unsupported(resolved)))
|
||||
}
|
||||
if (!native) return yield* unsupported(resolved)
|
||||
|
||||
@@ -159,7 +160,7 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
Effect.mapError(() => unsupported(resolved)),
|
||||
)
|
||||
const settings = {
|
||||
...(credential ? Struct.omit(mapped, ["accessToken", "apiKey", "authToken"]) : mapped),
|
||||
...(credential ? withoutNativeAuthSettings(mapped) : mapped),
|
||||
...(resolved.canonical === undefined ? {} : { provider: resolved.canonical }),
|
||||
...nativeCredentialSettings(specifier, credential),
|
||||
headers: Provider.mergeHeaders(mapping?.headers, resolved.headers),
|
||||
@@ -181,13 +182,11 @@ const resolveCatalogModel = Effect.fn("ModelResolver.resolveCatalogModel")(funct
|
||||
|
||||
function prepareRuntimeModel(model: Info, credential: Credential.Value | undefined) {
|
||||
if (model.settings?.apiKey !== "" && (credential?.type !== "key" || credential.metadata === undefined)) return model
|
||||
return {
|
||||
...model,
|
||||
...(model.settings?.apiKey === "" ? { settings: Struct.omit(model.settings, ["apiKey"]) } : {}),
|
||||
...(credential?.type === "key" && credential.metadata !== undefined
|
||||
? { body: Provider.mergeOverlay(model.body, credential.metadata) }
|
||||
: {}),
|
||||
}
|
||||
return produce(model, (draft) => {
|
||||
if (draft.settings?.apiKey === "") delete draft.settings.apiKey
|
||||
if (credential?.type === "key" && credential.metadata !== undefined)
|
||||
draft.body = Provider.mergeOverlay(draft.body, credential.metadata)
|
||||
})
|
||||
}
|
||||
|
||||
function validateProviderVariables(
|
||||
@@ -244,6 +243,11 @@ const nativeCredentialSettings = (specifier: string, credential: Credential.Valu
|
||||
return { apiKey: credential.access }
|
||||
}
|
||||
|
||||
const withoutNativeAuthSettings = (settings: Record<string, unknown>) => {
|
||||
const { accessToken: _accessToken, apiKey: _apiKey, authToken: _authToken, ...rest } = settings
|
||||
return rest
|
||||
}
|
||||
|
||||
const unsupported = (model: Info) =>
|
||||
new UnsupportedPackageError({
|
||||
providerID: model.providerID,
|
||||
|
||||
+65
-57
@@ -2,6 +2,7 @@ export * as Plugin from "./plugin.js"
|
||||
export { Event, ID, Info, Source, State } from "@opencode-ai/schema/plugin"
|
||||
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import type { Plugin as PluginDefinition } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import type { PersistentPty } from "./persistent-pty.js"
|
||||
@@ -9,10 +10,27 @@ import { Cause, Context, Effect, Exit, Latch, Layer, Logger, References, Scope,
|
||||
import { Bus } from "./bus.js"
|
||||
import { KV } from "./kv.js"
|
||||
import { PluginHost } from "./plugin/host.js"
|
||||
import { type Failure, type Generation, Service } from "./plugin/service.js"
|
||||
import { State } from "./state.js"
|
||||
|
||||
export { awaitActivation, type Generation, type Interface, Service } from "./plugin/service.js"
|
||||
export interface Interface {
|
||||
readonly activate: (plugins: readonly Generation[], failures?: readonly Failure[]) => Effect.Effect<void>
|
||||
readonly list: () => Effect.Effect<Plugin.Info[]>
|
||||
readonly close: (exit: Exit.Exit<unknown, unknown>) => Effect.Effect<void>
|
||||
/** Wait for announced updates and activation to settle; failures remain in the inventory. */
|
||||
readonly awaitActivation: Effect.Effect<void>
|
||||
/** Keep readiness pending while preparing an update. Run the returned Effect to release it. */
|
||||
readonly hold: () => Effect.Effect<Effect.Effect<void>>
|
||||
}
|
||||
|
||||
type Failure = Plugin.Info & { readonly state: Extract<Plugin.State, { readonly status: "failed" }> }
|
||||
|
||||
export type Generation = PluginDefinition & {
|
||||
readonly revision: string
|
||||
readonly source?: Plugin.Source
|
||||
readonly features?: Plugin.Features
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Plugin") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -20,9 +38,7 @@ const layer = Layer.effect(
|
||||
const bus = yield* Bus.Service
|
||||
const kv = yield* KV.Service
|
||||
const scope = yield* Scope.make()
|
||||
// One slot per requested definition in activation order, including ones whose setup failed, so
|
||||
// the prefix diff below stays index-aligned and a failed revision is not retried until it changes.
|
||||
const active = new Map<Plugin.ID, Slot>()
|
||||
const active = new Map<Plugin.ID, { readonly plugin: Generation; readonly scope: Scope.Closeable }>()
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const ready = yield* Latch.make(true)
|
||||
const pending = new Set<object>()
|
||||
@@ -54,6 +70,7 @@ const layer = Layer.effect(
|
||||
),
|
||||
),
|
||||
Effect.withSpan("Plugin.load", { attributes: { "plugin.id": plugin.id } }),
|
||||
Effect.andThen(bus.publish(Plugin.Event.Added, { id: Plugin.ID.make(plugin.id) })),
|
||||
Effect.onExit((exit) => (Exit.isFailure(exit) ? Scope.close(child, exit) : Effect.void)),
|
||||
Effect.exit,
|
||||
)
|
||||
@@ -81,18 +98,18 @@ const layer = Layer.effect(
|
||||
() =>
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
const current = Array.from(active.values())
|
||||
const changed = definitions.findIndex((definition, index) => {
|
||||
const entry = current[index]
|
||||
return entry?.plugin.id !== definition.id || entry.plugin.revision !== definition.revision
|
||||
})
|
||||
const prefix = changed === -1 ? definitions.length : changed
|
||||
for (const definition of definitions.slice(0, prefix)) {
|
||||
const entry = active.get(definition.id)
|
||||
if (entry) active.set(definition.id, { ...entry, plugin: definition })
|
||||
}
|
||||
if (prefix === definitions.length && active.size === definitions.length) {
|
||||
const nextInventory = [...Array.from(active.values()).map(slotInfo), ...failures]
|
||||
if (
|
||||
active.size === definitions.length &&
|
||||
Array.from(active.values()).every((entry, index) => {
|
||||
const definition = definitions[index]
|
||||
return entry.plugin.id === definition?.id && entry.plugin.revision === definition.revision
|
||||
})
|
||||
) {
|
||||
for (const definition of definitions) {
|
||||
const entry = active.get(definition.id)
|
||||
if (entry) active.set(definition.id, { ...entry, plugin: definition })
|
||||
}
|
||||
const nextInventory = [...definitions.map(activeInfo), ...failures]
|
||||
if (JSON.stringify(inventory) === JSON.stringify(nextInventory)) return
|
||||
inventory = nextInventory
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
@@ -101,37 +118,29 @@ const layer = Layer.effect(
|
||||
|
||||
yield* State.batch(
|
||||
Effect.gen(function* () {
|
||||
// Registrations are ordered by setup, so only the unchanged prefix can stay alive.
|
||||
const previous = new Map(Array.from(active.entries()).slice(prefix))
|
||||
yield* Effect.forEach(
|
||||
Array.from(previous.entries()).toReversed(),
|
||||
([id, slot]) =>
|
||||
Effect.gen(function* () {
|
||||
active.delete(id)
|
||||
if (slot.loaded) yield* Scope.close(slot.loaded.scope, Exit.void)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
for (const definition of definitions.slice(prefix)) {
|
||||
const nextInventory: Plugin.Info[] = []
|
||||
for (const definition of definitions) {
|
||||
const previous = active.get(definition.id)
|
||||
active.delete(definition.id)
|
||||
if (previous) yield* Scope.close(previous.scope, Exit.void)
|
||||
|
||||
const loaded = yield* load(definition)
|
||||
if (loaded.scope !== undefined) {
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: definition, scope: loaded.scope },
|
||||
})
|
||||
active.set(definition.id, { plugin: definition, scope: loaded.scope })
|
||||
nextInventory.push(activeInfo(definition))
|
||||
continue
|
||||
}
|
||||
active.set(definition.id, { plugin: definition, error: loaded.error })
|
||||
nextInventory.push({
|
||||
id: definition.id,
|
||||
source: definition.source ?? { type: "builtin" },
|
||||
state: { status: "failed", error: loaded.error },
|
||||
features: { server: true, ...definition.features },
|
||||
})
|
||||
|
||||
const fallback = previous.get(definition.id)?.loaded
|
||||
if (!fallback) continue
|
||||
const restored = yield* load(fallback.plugin)
|
||||
if (!previous) continue
|
||||
const restored = yield* load(previous.plugin)
|
||||
if (restored.scope !== undefined) {
|
||||
active.set(definition.id, {
|
||||
plugin: definition,
|
||||
loaded: { plugin: fallback.plugin, scope: restored.scope },
|
||||
error: loaded.error,
|
||||
})
|
||||
active.set(definition.id, { plugin: previous.plugin, scope: restored.scope })
|
||||
continue
|
||||
}
|
||||
yield* Effect.logError("failed to restore plugin; deactivating", {
|
||||
@@ -139,7 +148,14 @@ const layer = Layer.effect(
|
||||
})
|
||||
}
|
||||
|
||||
inventory = [...Array.from(active.values()).map(slotInfo), ...failures]
|
||||
const removed = Array.from(active.entries())
|
||||
.filter(([id]) => !ids.has(id))
|
||||
.toReversed()
|
||||
removed.forEach(([id]) => active.delete(id))
|
||||
yield* Effect.forEach(removed, ([, entry]) => Scope.close(entry.scope, Exit.void), {
|
||||
discard: true,
|
||||
})
|
||||
inventory = [...nextInventory, ...failures]
|
||||
}),
|
||||
)
|
||||
yield* bus.publish(Plugin.Event.Updated, {})
|
||||
@@ -153,7 +169,7 @@ const layer = Layer.effect(
|
||||
lock.withPermit(
|
||||
Effect.gen(function* () {
|
||||
active.clear()
|
||||
yield* State.shutdown(Scope.close(scope, exit))
|
||||
yield* State.batch(Scope.close(scope, exit), { flush: false })
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(close)
|
||||
@@ -168,20 +184,12 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
// `plugin` is the definition the slot was last asked to run; `loaded` is the generation actually
|
||||
// running, which stays an older fallback while the requested revision keeps failing setup.
|
||||
type Slot = {
|
||||
readonly plugin: Generation
|
||||
readonly loaded?: { readonly plugin: Generation; readonly scope: Scope.Closeable }
|
||||
readonly error?: string
|
||||
}
|
||||
|
||||
function slotInfo(slot: Slot): Plugin.Info {
|
||||
function activeInfo(plugin: Generation): Plugin.Info {
|
||||
return {
|
||||
id: Plugin.ID.make(slot.plugin.id),
|
||||
source: slot.plugin.source ?? { type: "builtin" },
|
||||
state: slot.error === undefined ? { status: "active" } : { status: "failed", error: slot.error },
|
||||
features: { server: true, ...slot.plugin.features },
|
||||
id: Plugin.ID.make(plugin.id),
|
||||
source: plugin.source ?? { type: "builtin" },
|
||||
state: { status: "active" },
|
||||
features: { server: true, ...plugin.features },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -83,15 +83,15 @@ Rules:
|
||||
export const Plugin = define({
|
||||
id: "opencode.agent",
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
yield* ctx.agent.transform((editor) => {
|
||||
editor.update(Agent.defaultID, (item) => {
|
||||
yield* ctx.agent.transform((draft) => {
|
||||
draft.update(Agent.defaultID, (item) => {
|
||||
item.name = Agent.Name.make("Build")
|
||||
item.description = "The default agent. Executes tools based on configured permissions."
|
||||
item.mode = "primary"
|
||||
item.permissions.push({ action: "question", resource: "*", effect: "allow" })
|
||||
})
|
||||
|
||||
editor.update(Agent.ID.make("general"), (item) => {
|
||||
draft.update(Agent.ID.make("general"), (item) => {
|
||||
item.name = Agent.Name.make("General")
|
||||
item.description =
|
||||
"General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel."
|
||||
@@ -102,7 +102,7 @@ export const Plugin = define({
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(Agent.ID.make("explore"), (item) => {
|
||||
draft.update(Agent.ID.make("explore"), (item) => {
|
||||
const externalDirectories = item.permissions.filter(
|
||||
(rule) => rule.action === "external_directory" && rule.effect === "allow",
|
||||
)
|
||||
@@ -130,13 +130,13 @@ export const Plugin = define({
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(Agent.ID.make("compaction"), (item) => {
|
||||
draft.update(Agent.ID.make("compaction"), (item) => {
|
||||
item.name = Agent.Name.make("Compaction")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
})
|
||||
|
||||
editor.update(Agent.ID.make("title"), (item) => {
|
||||
draft.update(Agent.ID.make("title"), (item) => {
|
||||
item.name = Agent.Name.make("Title")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
@@ -144,7 +144,7 @@ export const Plugin = define({
|
||||
item.permissions.push({ action: "*", resource: "*", effect: "deny" })
|
||||
})
|
||||
|
||||
editor.update(Agent.ID.make("summary"), (item) => {
|
||||
draft.update(Agent.ID.make("summary"), (item) => {
|
||||
item.name = Agent.Name.make("Summary")
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
|
||||
@@ -25,8 +25,8 @@ export const Plugin = define({
|
||||
Effect.forkScoped({ startImmediately: true }),
|
||||
)
|
||||
loaded.prompts = yield* mcp.prompts()
|
||||
yield* ctx.command.transform((editor) => {
|
||||
editor.add({
|
||||
yield* ctx.command.transform((draft) => {
|
||||
draft.add({
|
||||
name: "init",
|
||||
description: "guided AGENTS.md setup",
|
||||
execute: (input) =>
|
||||
@@ -39,7 +39,7 @@ export const Plugin = define({
|
||||
})
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
editor.add({
|
||||
draft.add({
|
||||
name: "review",
|
||||
description: "review changes [commit|branch|pr], defaults to uncommitted",
|
||||
execute: (input) =>
|
||||
@@ -53,7 +53,7 @@ export const Plugin = define({
|
||||
.pipe(Effect.asVoid),
|
||||
})
|
||||
for (const prompt of loaded.prompts) {
|
||||
editor.add({
|
||||
draft.add({
|
||||
name: mcpCommandName(prompt.server, prompt.name),
|
||||
description: prompt.description,
|
||||
execute: (input) =>
|
||||
|
||||
@@ -136,13 +136,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
},
|
||||
reload: agents.reload,
|
||||
transform: (callback) =>
|
||||
agents.transform((editor) => {
|
||||
agents.transform((draft) => {
|
||||
callback({
|
||||
list: () => mutable(editor.list()),
|
||||
get: (id) => mutable(editor.get(Agent.ID.make(id))),
|
||||
default: (id) => editor.default(id === undefined ? undefined : Agent.ID.make(id)),
|
||||
update: (id, update) => editor.update(Agent.ID.make(id), update),
|
||||
remove: (id) => editor.remove(Agent.ID.make(id)),
|
||||
list: () => mutable(draft.list()),
|
||||
get: (id) => mutable(draft.get(Agent.ID.make(id))),
|
||||
default: (id) => draft.default(id === undefined ? undefined : Agent.ID.make(id)),
|
||||
update: (id, update) => draft.update(Agent.ID.make(id), update),
|
||||
remove: (id) => draft.remove(Agent.ID.make(id)),
|
||||
})
|
||||
}),
|
||||
},
|
||||
@@ -196,25 +196,24 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
},
|
||||
reload: catalog.reload,
|
||||
transform: (callback) =>
|
||||
catalog.transform((editor) => {
|
||||
catalog.transform((draft) => {
|
||||
callback({
|
||||
provider: {
|
||||
list: () => mutable(editor.provider.list()),
|
||||
get: (id) => mutable(editor.provider.get(Provider.ID.make(id))),
|
||||
update: (id, update) => editor.provider.update(Provider.ID.make(id), update),
|
||||
remove: (id) => editor.provider.remove(Provider.ID.make(id)),
|
||||
list: () => mutable(draft.provider.list()),
|
||||
get: (id) => mutable(draft.provider.get(Provider.ID.make(id))),
|
||||
update: (id, update) => draft.provider.update(Provider.ID.make(id), update),
|
||||
remove: (id) => draft.provider.remove(Provider.ID.make(id)),
|
||||
},
|
||||
model: {
|
||||
get: (providerID, modelID) =>
|
||||
mutable(editor.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))),
|
||||
mutable(draft.model.get(Provider.ID.make(providerID), Model.ID.make(modelID))),
|
||||
update: (providerID, modelID, update) =>
|
||||
editor.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update),
|
||||
remove: (providerID, modelID) =>
|
||||
editor.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
||||
draft.model.update(Provider.ID.make(providerID), Model.ID.make(modelID), update),
|
||||
remove: (providerID, modelID) => draft.model.remove(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
||||
default: {
|
||||
get: editor.model.default.get,
|
||||
get: draft.model.default.get,
|
||||
set: (providerID, modelID) =>
|
||||
editor.model.default.set(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
||||
draft.model.default.set(Provider.ID.make(providerID), Model.ID.make(modelID)),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -316,17 +315,17 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
),
|
||||
},
|
||||
transform: (callback) =>
|
||||
integration.transform((editor) => {
|
||||
integration.transform((draft) => {
|
||||
callback({
|
||||
list: () => mutable(editor.list()),
|
||||
get: (id) => mutable(editor.get(Integration.ID.make(id))),
|
||||
update: (id, update) => editor.update(Integration.ID.make(id), update),
|
||||
remove: (id) => editor.remove(Integration.ID.make(id)),
|
||||
list: () => mutable(draft.list()),
|
||||
get: (id) => mutable(draft.get(Integration.ID.make(id))),
|
||||
update: (id, update) => draft.update(Integration.ID.make(id), update),
|
||||
remove: (id) => draft.remove(Integration.ID.make(id)),
|
||||
method: {
|
||||
list: (id) => editor.method.list(Integration.ID.make(id)),
|
||||
update: (input) => editor.method.update(methodImplementation(input)),
|
||||
list: (id) => draft.method.list(Integration.ID.make(id)),
|
||||
update: (input) => draft.method.update(methodImplementation(input)),
|
||||
remove: (id, method) =>
|
||||
editor.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
draft.method.remove(Integration.ID.make(id), Schema.decodeUnknownSync(Integration.Method)(method)),
|
||||
},
|
||||
})
|
||||
}),
|
||||
@@ -351,13 +350,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
},
|
||||
reload: mcp.reload,
|
||||
transform: (callback) =>
|
||||
mcp.transform((editor) => {
|
||||
mcp.transform((draft) => {
|
||||
callback({
|
||||
list: () => editor.list().map(([name, config]) => [name, mutable(config)]),
|
||||
get: (name) => mutable(editor.get(name)),
|
||||
set: (name, config) => editor.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
|
||||
update: editor.update,
|
||||
remove: editor.remove,
|
||||
list: () => draft.list().map(([name, config]) => [name, mutable(config)]),
|
||||
get: (name) => mutable(draft.get(name)),
|
||||
set: (name, config) => draft.set(name, Schema.decodeUnknownSync(ServerConfig)(config)),
|
||||
update: draft.update,
|
||||
remove: draft.remove,
|
||||
})
|
||||
}),
|
||||
},
|
||||
@@ -392,11 +391,11 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
list: () => response(reference.list()),
|
||||
reload: reference.reload,
|
||||
transform: (callback) =>
|
||||
reference.transform((editor) => {
|
||||
reference.transform((draft) => {
|
||||
callback({
|
||||
add: (name, source) => editor.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
|
||||
remove: editor.remove,
|
||||
list: editor.list,
|
||||
add: (name, source) => draft.add(name, Schema.decodeUnknownSync(Reference.Source)(source)),
|
||||
remove: draft.remove,
|
||||
list: draft.list,
|
||||
})
|
||||
}),
|
||||
},
|
||||
@@ -404,12 +403,12 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
list: () => response(skill.list()),
|
||||
reload: skill.reload,
|
||||
transform: (callback) =>
|
||||
skill.transform((editor) => {
|
||||
skill.transform((draft) => {
|
||||
callback({
|
||||
list: () => mutable(editor.list()),
|
||||
add: (value) => editor.add(Schema.decodeUnknownSync(Skill.Info)(value)),
|
||||
update: editor.update,
|
||||
remove: editor.remove,
|
||||
list: () => mutable(draft.list()),
|
||||
add: (value) => draft.add(Schema.decodeUnknownSync(Skill.Info)(value)),
|
||||
update: draft.update,
|
||||
remove: draft.remove,
|
||||
})
|
||||
}),
|
||||
},
|
||||
@@ -442,18 +441,18 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
),
|
||||
reload: websearch.reload,
|
||||
transform: (callback) =>
|
||||
websearch.transform((editor) => {
|
||||
websearch.transform((draft) => {
|
||||
callback({
|
||||
add: (definition) =>
|
||||
editor.add({
|
||||
draft.add({
|
||||
id: WebSearch.ID.make(definition.id),
|
||||
name: definition.name,
|
||||
execute: definition.execute,
|
||||
}),
|
||||
default: {
|
||||
get: editor.default.get,
|
||||
get: draft.default.get,
|
||||
set: (selection) =>
|
||||
editor.default.set(
|
||||
draft.default.set(
|
||||
selection === false || selection === "random" ? selection : WebSearch.ID.make(selection),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -63,7 +63,6 @@ import { PatchTool } from "../tool/plugin/patch.js"
|
||||
import { EditTool } from "../tool/plugin/edit.js"
|
||||
import { GlobTool } from "../tool/plugin/glob.js"
|
||||
import { GrepTool } from "../tool/plugin/grep.js"
|
||||
import { OpenCodeTools } from "../tool/plugin/opencode.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
import { ReadToolFileSystem } from "../tool/read-filesystem.js"
|
||||
import { ReadTool } from "../tool/plugin/read.js"
|
||||
@@ -205,7 +204,6 @@ const pre = [
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
GrepTool.Plugin,
|
||||
OpenCodeTools.Plugin,
|
||||
QuestionTool.Plugin,
|
||||
ReadTool.Plugin,
|
||||
ShellTool.Plugin,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user