mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-27 05:35:46 -04:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c25ce08008 | |||
| 4216d35e4b | |||
| 863645c671 | |||
| 5592f5225b | |||
| 8db7487c89 | |||
| 0fd73a2976 | |||
| 80865407e0 | |||
| 28f4284bd7 | |||
| 7affee529b | |||
| 79c7e9446e | |||
| efb629a33a | |||
| 2ddc91a0e8 | |||
| c7871e14d4 | |||
| 9840f63b12 | |||
| 56a9c0150a | |||
| 203a0613b8 | |||
| 7d8f1bdab3 | |||
| 9eea5bc925 | |||
| f753103e82 | |||
| 1e35d33ecb | |||
| c5bf4edb10 | |||
| cce8bb0e1c | |||
| 02c66c5fc1 | |||
| 33390cc457 | |||
| b2afb35527 | |||
| 828148909d | |||
| 454145fe65 | |||
| 1291dc1f11 | |||
| c7d7f61146 | |||
| 13b6845e7e | |||
| d1d97014b4 | |||
| b31747124b | |||
| 49bec25ae5 | |||
| d66d0cb904 | |||
| 3193f3aa95 | |||
| ee5460a152 | |||
| b09a066fb5 | |||
| 423fad730c | |||
| 5ae2d6d3f6 | |||
| 993f046dd9 |
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/protocol": patch
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose background-service lifecycle status, preserve one process-held owner through startup and failure, reconnect TUIs without activating replacement, and stop exact service instances gracefully.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot at the top of the session view.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/plugin": minor
|
||||
"@opencode-ai/sdk": minor
|
||||
"@opencode-ai/client": minor
|
||||
"@opencode-ai/protocol": minor
|
||||
---
|
||||
|
||||
Replace the V2 tool result model with one canonical representation per fact. Tools lose `structured`, projection callbacks, the `Structured` generic, and the exported `Tool.settle` interpreter; tool responses carry schema-validated `output`, model-visible `content`, and optional compact JSON `metadata`. Code Mode receives the validated encoded output. Durable tool success stores non-empty model content plus optional metadata; failure stores one error plus the final bounded partial snapshot. Progress carries metadata only, while `execute.after` hooks receive the canonical terminal outcome and managed `outputPaths`. A one-time migration rewrites existing projected tool rows and moves provider-hosted result payloads into provider-owned result state.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/client": patch
|
||||
"@opencode-ai/plugin": patch
|
||||
"@opencode-ai/protocol": patch
|
||||
---
|
||||
|
||||
Expose transient, read-only session generation through the HTTP API, generated clients, and V2 plugin session context.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@opencode-ai/cli": patch
|
||||
---
|
||||
|
||||
Expose a TUI plugin slot above the session composer.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: deploy-www
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- v2
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-www-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'v2')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Build
|
||||
working-directory: packages/www
|
||||
run: bun run build
|
||||
env:
|
||||
BLUME_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
CLOUDFLARE_ENV: ${{ github.ref_name == 'v2' && 'production' || 'dev' }}
|
||||
|
||||
- name: Deploy
|
||||
working-directory: packages/www
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
- name: Check generated documentation
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: packages/docs
|
||||
working-directory: packages/www
|
||||
run: bun run check:generated
|
||||
|
||||
e2e:
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
- To regenerate the legacy JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit `src/generated` or `src/generated-effect` directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk-next` composes Client, Core, and Server.
|
||||
- Do not modify `packages/opencode` unless the user explicitly asks for V1 work. `packages/opencode` is the V1 implementation and is present for reference only. New implementation changes should land in the V2 package set: `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
+1
-1
@@ -15,6 +15,6 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@octokit/graphql": "9.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*"
|
||||
"@opencode-ai/sdk": "1.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
+9
-8
@@ -33,13 +33,12 @@
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/stats/*",
|
||||
"packages/sdk/js",
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.98",
|
||||
"@effect/platform-node": "4.0.0-beta.98",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.98",
|
||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||
"@effect/platform-node": "4.0.0-beta.101",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -51,6 +50,7 @@
|
||||
"@opentui/solid": "0.4.5",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
@@ -69,7 +69,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.98",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -124,7 +124,7 @@
|
||||
"@aws-sdk/client-s3": "3.933.0",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"heap-snapshot-toolkit": "1.1.3",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
@@ -152,7 +152,8 @@
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:"
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
@@ -166,7 +167,7 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.98": "patches/effect@4.0.0-beta.98.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Conventions
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generateTurn`, `LLM.streamTurn`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Model.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, `LLM.updateRequest`, and `LLM.generateObject`. Two ways to construct the same thing is one too many.
|
||||
|
||||
## Tests
|
||||
|
||||
@@ -223,7 +223,7 @@ Routes lower these into provider-native assistant tool-call messages and tool-re
|
||||
|
||||
### Tool dispatch
|
||||
|
||||
`LLM.streamTurn(request)` and `LLM.generateTurn(request)` each run exactly one provider turn. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`.
|
||||
`LLM.stream(request)` and `LLM.generate(request)` each run exactly one provider turn. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`.
|
||||
|
||||
```ts
|
||||
const get_weather = tool({
|
||||
@@ -240,7 +240,7 @@ const get_weather = tool({
|
||||
})
|
||||
|
||||
const tools = { get_weather, get_time, ... }
|
||||
const events = yield* LLM.streamTurn(
|
||||
const events = yield* LLM.stream(
|
||||
LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }),
|
||||
).pipe(Stream.runCollect)
|
||||
|
||||
|
||||
@@ -175,8 +175,8 @@ const request = LLM.request({
|
||||
prompt: "Say hello.",
|
||||
})
|
||||
|
||||
// Current API: this performs one provider turn.
|
||||
const response = yield * LLM.generateTurn(request)
|
||||
// Current API: this performs one provider turn, despite the broad name.
|
||||
const response = yield * LLM.generate(request)
|
||||
|
||||
// Current API: execution also needs LLMClient.layer and RequestExecutor services.
|
||||
```
|
||||
@@ -432,7 +432,7 @@ const request = LLM.request({
|
||||
tools: Tool.toDefinitions(tools),
|
||||
})
|
||||
|
||||
const events = yield * LLM.streamTurn(request).pipe(Stream.runCollect)
|
||||
const events = yield * LLM.stream(request).pipe(Stream.runCollect)
|
||||
const call = Array.from(events).find(LLMEvent.is.toolCall)
|
||||
|
||||
if (call && !call.providerExecuted) {
|
||||
@@ -1076,7 +1076,7 @@ The redesign intentionally removes or changes these current concepts:
|
||||
| Current | Proposed |
|
||||
| --------------------------------------- | ----------------------------------------------------------- |
|
||||
| Mandatory `LLM.request({ model, ... })` | Inline calls or model-free portable requests |
|
||||
| No complete-run API | Add `LLM.generate` / `LLM.stream` |
|
||||
| `LLM.generate` means one turn | `LLM.generate` means complete run |
|
||||
| `LLMClient.generate/stream` | `LLM.generateTurn/streamTurn` for one turn |
|
||||
| `LLMClient.layer` requirement | Standard Effect requirements exposed directly |
|
||||
| Public `Route` mental model | Hidden behind executable `Model` |
|
||||
|
||||
@@ -176,7 +176,7 @@ Conversational image generation remains part of the LLM interaction. OpenAI Resp
|
||||
|
||||
```ts
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLM.generateTurn(
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
|
||||
prompt: "Design a solarpunk rooftop garden, then show me.",
|
||||
@@ -193,7 +193,7 @@ The hosted result is represented as a provider-executed tool call and tool resul
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
- **`LLM.generateTurn` / `LLM.streamTurn`** — execute exactly one provider turn, re-exported from `LLMClient` for one-import use.
|
||||
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
||||
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
|
||||
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
||||
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
||||
@@ -207,7 +207,9 @@ Prompt caching is **on by default**. Every `LLMRequest` resolves to `cache: "aut
|
||||
|
||||
### Auto placement
|
||||
|
||||
`"auto"` places three breakpoints — last tool definition, last system part, latest user message. The last-user-message boundary is the load-bearing detail: in a tool-use loop, a single user turn expands into many assistant/tool round-trips, all sharing that prefix. Caching at that boundary lets every intra-turn API call hit.
|
||||
`"auto"` places up to four breakpoints — the last tool definition, the first system part, the last system part when distinct, and the final message boundary. These expose successively larger reusable prefixes for tools, the base agent, project instructions, and the active conversation. The rolling final-message boundary is the load-bearing detail in tool loops: it advances on every request so the previous cache entry stays within Anthropic's 20-block lookback.
|
||||
|
||||
Tools precede every system and conversation block in the provider prefix, so tool definitions must remain byte-stable and deterministically ordered for downstream breakpoints to remain reusable.
|
||||
|
||||
The math justifies the default: Anthropic's 5-minute cache write is 1.25× base, read is 0.1×, so a single reuse within 5 minutes already wins. One-shot completions below the per-model minimum-cacheable-token threshold silently no-op on the wire, so the worst case is harmless.
|
||||
|
||||
@@ -235,7 +237,7 @@ cache: {
|
||||
|
||||
### Manual hints
|
||||
|
||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints; it only fills gaps.
|
||||
Inline `CacheHint` on any text / system / tool / tool-result part overrides automatic placement. The auto policy preserves manual hints, counts them against Anthropic and Bedrock's four-breakpoint limit, and only fills the remaining slots.
|
||||
|
||||
```ts
|
||||
LLM.request({
|
||||
@@ -251,8 +253,8 @@ LLM.request({
|
||||
|
||||
| Protocol | `cache: "auto"` |
|
||||
| ----------------------- | ------------------------------------------------------------------------- |
|
||||
| Anthropic Messages | emits up to 3 `cache_control` markers (4-breakpoint cap enforced) |
|
||||
| Bedrock Converse | emits up to 3 `cachePoint` blocks (4-breakpoint cap enforced) |
|
||||
| Anthropic Messages | emits up to 4 `cache_control` markers (4-breakpoint cap enforced) |
|
||||
| Bedrock Converse | emits up to 4 `cachePoint` blocks (4-breakpoint cap enforced) |
|
||||
| OpenAI Chat / Responses | no-op (implicit caching above 1024 tokens) |
|
||||
| Gemini | no-op (implicit caching on 2.5+; explicit `CachedContent` is out-of-band) |
|
||||
|
||||
|
||||
@@ -334,7 +334,7 @@ Final request call site stays boring:
|
||||
```ts
|
||||
const response =
|
||||
yield *
|
||||
LLM.generateTurn(
|
||||
LLM.generate(
|
||||
LLM.request({
|
||||
model: DeepSeek.model("deepseek-chat"),
|
||||
prompt: "Hello.",
|
||||
|
||||
@@ -65,7 +65,7 @@ const rawOverlayExample = LLM.request({
|
||||
// 3. `generate` sends the request and collects the event stream into one
|
||||
// response object. `response.text` is the collected text output.
|
||||
const generateOnce = Effect.gen(function* () {
|
||||
const response = yield* LLM.generateTurn(request)
|
||||
const response = yield* LLM.generate(request)
|
||||
|
||||
console.log("\n== generate ==")
|
||||
console.log("generated text:", response.text)
|
||||
@@ -74,7 +74,7 @@ const generateOnce = Effect.gen(function* () {
|
||||
|
||||
// 4. `stream` exposes provider output as common `LLMEvent`s for UIs that want
|
||||
// incremental text, reasoning, tool input, usage, or finish events.
|
||||
const streamText = LLM.streamTurn(request).pipe(
|
||||
const streamText = LLM.stream(request).pipe(
|
||||
Stream.tap((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === "text-delta") process.stdout.write(`\ntext: ${event.text}`)
|
||||
@@ -106,7 +106,7 @@ const streamWithTools = Effect.gen(function* () {
|
||||
generation: { maxTokens: 80, temperature: 0 },
|
||||
tools: Tool.toDefinitions(tools),
|
||||
})
|
||||
const events = Array.from(yield* LLM.streamTurn(request).pipe(Stream.runCollect))
|
||||
const events = Array.from(yield* LLM.stream(request).pipe(Stream.runCollect))
|
||||
for (const event of events) {
|
||||
if (event.type === "tool-call") console.log("tool call", event.name, event.input)
|
||||
if (event.type === "text-delta") process.stdout.write(event.text)
|
||||
|
||||
@@ -2,32 +2,31 @@
|
||||
// the policy designates. Runs once at compile time, before the per-protocol
|
||||
// body builder, so the existing inline-hint lowering path handles the rest.
|
||||
//
|
||||
// The default `"auto"` shape places one breakpoint at the last tool definition,
|
||||
// one at the last system part, and one at the latest user message. This
|
||||
// matches what production agent harnesses (LangChain's caching middleware,
|
||||
// kern-ai's 10x cost-reduction playbook) converge on for tool-use loops: the
|
||||
// latest user message stays put while a single turn explodes into many
|
||||
// assistant/tool round-trips, so caching at that boundary lets every
|
||||
// intra-turn API call hit the prefix.
|
||||
// The default `"auto"` shape places breakpoints at the last tool definition,
|
||||
// the first and last distinct system parts, and the conversation tail. This
|
||||
// exposes reusable tool, base-agent, project, and session prefixes while
|
||||
// advancing the tail after each tool result keeps the previous cache entry
|
||||
// within Anthropic's 20-block lookback during long agent turns.
|
||||
//
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved —
|
||||
// this function only fills gaps the caller left empty.
|
||||
// Manual `cache: CacheHint` placements on individual parts are preserved and
|
||||
// count against the four-breakpoint budget; auto only fills remaining slots.
|
||||
import { CacheHint, type CachePolicy, type CachePolicyObject } from "./schema/options"
|
||||
import { LLMRequest, Message, ToolDefinition, type ContentPart } from "./schema/messages"
|
||||
|
||||
const AUTO: CachePolicyObject = {
|
||||
tools: true,
|
||||
system: true,
|
||||
messages: "latest-user-message",
|
||||
messages: { tail: 1 },
|
||||
}
|
||||
|
||||
const NONE: CachePolicyObject = {}
|
||||
const BREAKPOINT_CAP = 4
|
||||
|
||||
// Resolution rules:
|
||||
// - undefined → "auto" — caching is on by default. The math favors it:
|
||||
// Anthropic 5m-cache write is 1.25x base, read is 0.1x,
|
||||
// so a single reuse within 5 minutes already wins.
|
||||
// - "auto" → tools + system + latest user msg.
|
||||
// - "auto" → tools + first/last system + final message boundary.
|
||||
// - "none" → no auto placement; manual `CacheHint`s still flow.
|
||||
// - object form → exactly what the caller asked for.
|
||||
const resolve = (policy: CachePolicy | undefined): CachePolicyObject => {
|
||||
@@ -44,18 +43,32 @@ const RESPECTS_INLINE_HINTS = new Set(["anthropic-messages", "bedrock-converse"]
|
||||
const makeHint = (ttlSeconds: number | undefined): CacheHint =>
|
||||
ttlSeconds !== undefined ? new CacheHint({ type: "ephemeral", ttlSeconds }) : new CacheHint({ type: "ephemeral" })
|
||||
|
||||
const markLastTool = (tools: ReadonlyArray<ToolDefinition>, hint: CacheHint): ReadonlyArray<ToolDefinition> => {
|
||||
interface Budget {
|
||||
remaining: number
|
||||
}
|
||||
|
||||
const markLastTool = (
|
||||
tools: ReadonlyArray<ToolDefinition>,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): ReadonlyArray<ToolDefinition> => {
|
||||
if (tools.length === 0) return tools
|
||||
const last = tools.length - 1
|
||||
if (tools[last]!.cache) return tools
|
||||
if (tools[last]!.cache || budget.remaining === 0) return tools
|
||||
budget.remaining -= 1
|
||||
return tools.map((tool, i) => (i === last ? new ToolDefinition({ ...tool, cache: hint }) : tool))
|
||||
}
|
||||
|
||||
const markLastSystem = (system: LLMRequest["system"], hint: CacheHint): LLMRequest["system"] => {
|
||||
const markSystemBoundaries = (system: LLMRequest["system"], hint: CacheHint, budget: Budget): LLMRequest["system"] => {
|
||||
if (system.length === 0) return system
|
||||
const last = system.length - 1
|
||||
if (system[last]!.cache) return system
|
||||
return system.map((part, i) => (i === last ? { ...part, cache: hint } : part))
|
||||
let changed = false
|
||||
const next = system.map((part, index) => {
|
||||
if ((index !== 0 && index !== system.length - 1) || part.cache || budget.remaining === 0) return part
|
||||
budget.remaining -= 1
|
||||
changed = true
|
||||
return { ...part, cache: hint }
|
||||
})
|
||||
return changed ? next : system
|
||||
}
|
||||
|
||||
const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]): number =>
|
||||
@@ -64,14 +77,20 @@ const lastIndexOfRole = (messages: ReadonlyArray<Message>, role: Message["role"]
|
||||
// Mark the last text part of `messages[index]`. If no text part exists, mark
|
||||
// the last content part regardless of type — that's the breakpoint position
|
||||
// in tool-result-only messages too.
|
||||
const markMessageAt = (messages: ReadonlyArray<Message>, index: number, hint: CacheHint): ReadonlyArray<Message> => {
|
||||
const markMessageAt = (
|
||||
messages: ReadonlyArray<Message>,
|
||||
index: number,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): ReadonlyArray<Message> => {
|
||||
if (index < 0 || index >= messages.length) return messages
|
||||
const target = messages[index]!
|
||||
if (target.content.length === 0) return messages
|
||||
const lastTextIndex = target.content.findLastIndex((part) => part.type === "text")
|
||||
const markAt = lastTextIndex >= 0 ? lastTextIndex : target.content.length - 1
|
||||
const existing = target.content[markAt]!
|
||||
if ("cache" in existing && existing.cache) return messages
|
||||
if (("cache" in existing && existing.cache) || budget.remaining === 0) return messages
|
||||
budget.remaining -= 1
|
||||
const nextContent = target.content.map((part, i) => (i === markAt ? ({ ...part, cache: hint } as ContentPart) : part))
|
||||
const next = new Message({ ...target, content: nextContent })
|
||||
// Single pass over `messages`, substituting the one updated entry. Long
|
||||
@@ -86,25 +105,42 @@ const markMessages = (
|
||||
messages: ReadonlyArray<Message>,
|
||||
strategy: NonNullable<CachePolicyObject["messages"]>,
|
||||
hint: CacheHint,
|
||||
budget: Budget,
|
||||
): ReadonlyArray<Message> => {
|
||||
if (messages.length === 0) return messages
|
||||
if (strategy === "latest-user-message") return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint)
|
||||
if (strategy === "latest-assistant") return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint)
|
||||
if (strategy === "latest-user-message")
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "user"), hint, budget)
|
||||
if (strategy === "latest-assistant")
|
||||
return markMessageAt(messages, lastIndexOfRole(messages, "assistant"), hint, budget)
|
||||
const start = Math.max(0, messages.length - strategy.tail)
|
||||
let next = messages
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint)
|
||||
for (let i = start; i < messages.length; i++) next = markMessageAt(next, i, hint, budget)
|
||||
return next
|
||||
}
|
||||
|
||||
const countHints = (request: LLMRequest) =>
|
||||
request.tools.reduce((count, tool) => count + (tool.cache === undefined ? 0 : 1), 0) +
|
||||
request.system.reduce((count, part) => count + (part.cache === undefined ? 0 : 1), 0) +
|
||||
request.messages.reduce(
|
||||
(count, message) =>
|
||||
count +
|
||||
message.content.reduce(
|
||||
(contentCount, part) => contentCount + ("cache" in part && part.cache !== undefined ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
0,
|
||||
)
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (!RESPECTS_INLINE_HINTS.has(request.model.route.id)) return request
|
||||
const policy = resolve(request.cache)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
const hint = makeHint(policy.ttlSeconds)
|
||||
const tools = policy.tools ? markLastTool(request.tools, hint) : request.tools
|
||||
const system = policy.system ? markLastSystem(request.system, hint) : request.system
|
||||
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint) : request.messages
|
||||
const budget = { remaining: Math.max(0, BREAKPOINT_CAP - countHints(request)) }
|
||||
const tools = policy.tools ? markLastTool(request.tools, hint, budget) : request.tools
|
||||
const system = policy.system ? markSystemBoundaries(request.system, hint, budget) : request.system
|
||||
const messages = policy.messages ? markMessages(request.messages, policy.messages, hint, budget) : request.messages
|
||||
|
||||
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
||||
return LLMRequest.update(request, { tools, system, messages })
|
||||
|
||||
@@ -31,9 +31,9 @@ export type RequestInput = Omit<
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
export const generateTurn = LLMClient.generate
|
||||
export const generate = LLMClient.generate
|
||||
|
||||
export const streamTurn = LLMClient.stream
|
||||
export const stream = LLMClient.stream
|
||||
|
||||
export const request = (input: RequestInput) => {
|
||||
const {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -7,8 +8,10 @@ import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
LLMError,
|
||||
LLMEvent,
|
||||
mergeJsonRecords,
|
||||
Usage,
|
||||
type CacheHint,
|
||||
type FinishReasonDetails,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
@@ -17,7 +20,6 @@ import {
|
||||
type ProviderMetadata,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -233,12 +235,27 @@ const AnthropicBodyFields = {
|
||||
export const AnthropicMessagesBody = Schema.Struct(AnthropicBodyFields)
|
||||
export type AnthropicMessagesBody = Schema.Schema.Type<typeof AnthropicMessagesBody>
|
||||
|
||||
const AnthropicUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||
})
|
||||
const AnthropicUsage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
cache_creation_input_tokens: optionalNull(Schema.Number),
|
||||
cache_read_input_tokens: optionalNull(Schema.Number),
|
||||
server_tool_use: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ web_search_requests: Schema.optional(Schema.Number) }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
output_tokens_details: optionalNull(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({ thinking_tokens: Schema.optional(Schema.Number) }),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
|
||||
|
||||
const AnthropicStreamBlock = Schema.Struct({
|
||||
@@ -288,7 +305,12 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
|
||||
interface ParserState {
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly reasoningSignatures: Readonly<Record<number, string>>
|
||||
readonly usage?: Usage
|
||||
readonly pendingFinish?: {
|
||||
readonly reason: FinishReasonDetails
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
}
|
||||
readonly lifecycle: Lifecycle.State
|
||||
}
|
||||
|
||||
@@ -403,7 +425,7 @@ const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: Me
|
||||
// Tool results may carry structured text, images, and documents. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
|
||||
item: ToolContent,
|
||||
item: Tool.Content,
|
||||
) {
|
||||
if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
|
||||
return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
|
||||
@@ -414,7 +436,7 @@ const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultConte
|
||||
// with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
return yield* Effect.forEach(content, lowerToolResultContentItem)
|
||||
})
|
||||
|
||||
@@ -660,9 +682,8 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
// `input_tokens` is the *non-cached* count per the Messages API docs, with
|
||||
// cache reads and writes as separate fields. We sum them to derive the
|
||||
// inclusive `inputTokens` the rest of the contract expects. Extended
|
||||
// thinking tokens are *not* broken out by Anthropic — they're billed as
|
||||
// part of `output_tokens`, so `reasoningTokens` stays `undefined` and
|
||||
// `outputTokens` carries the combined total.
|
||||
// thinking tokens are included in `output_tokens`; newer responses also
|
||||
// expose that subset through `output_tokens_details.thinking_tokens`.
|
||||
const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const nonCached = usage.input_tokens
|
||||
@@ -675,6 +696,7 @@ const mapUsage = (usage: AnthropicUsage | undefined): Usage | undefined => {
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: usage.output_tokens_details?.thinking_tokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, usage.output_tokens, undefined),
|
||||
providerMetadata: { anthropic: usage },
|
||||
})
|
||||
@@ -693,18 +715,18 @@ const mergeUsage = (left: Usage | undefined, right: Usage | undefined) => {
|
||||
const cacheWriteInputTokens = right.cacheWriteInputTokens ?? left.cacheWriteInputTokens
|
||||
const inputTokens = ProviderShared.sumTokens(nonCachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens)
|
||||
const outputTokens = right.outputTokens ?? left.outputTokens
|
||||
const reasoningTokens = right.reasoningTokens ?? left.reasoningTokens
|
||||
return new Usage({
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
nonCachedInputTokens,
|
||||
cacheReadInputTokens,
|
||||
cacheWriteInputTokens,
|
||||
reasoningTokens,
|
||||
totalTokens: ProviderShared.totalTokens(inputTokens, outputTokens, undefined),
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
...left.providerMetadata?.["anthropic"],
|
||||
...right.providerMetadata?.["anthropic"],
|
||||
},
|
||||
anthropic:
|
||||
mergeJsonRecords(left.providerMetadata?.["anthropic"], right.providerMetadata?.["anthropic"]) ?? {},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -763,6 +785,10 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
tools: ToolStream.start(state.tools, event.index, {
|
||||
id: block.id ?? String(event.index),
|
||||
name: block.name ?? "",
|
||||
input:
|
||||
block.input !== undefined && (!ProviderShared.isRecord(block.input) || Object.keys(block.input).length > 0)
|
||||
? ProviderShared.encodeJson(block.input)
|
||||
: undefined,
|
||||
providerExecuted: block.type === "server_tool_use",
|
||||
}),
|
||||
},
|
||||
@@ -777,20 +803,31 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
]
|
||||
}
|
||||
|
||||
if (block.type === "text" && block.text) {
|
||||
if (block.type === "text" && block.text !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const id = `text-${event.index ?? 0}`
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id)
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
|
||||
{ ...state, lifecycle: block.text ? Lifecycle.textDelta(lifecycle, events, id, block.text) : lifecycle },
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.thinking) {
|
||||
if (block.type === "thinking" && block.thinking !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const id = `reasoning-${event.index ?? 0}`
|
||||
const providerMetadata = block.signature === undefined ? undefined : anthropicMetadata({ signature: block.signature })
|
||||
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, providerMetadata)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
|
||||
lifecycle: block.thinking
|
||||
? Lifecycle.reasoningDelta(lifecycle, events, id, block.thinking, providerMetadata)
|
||||
: lifecycle,
|
||||
reasoningSignatures:
|
||||
event.index === undefined || block.signature === undefined
|
||||
? state.reasoningSignatures
|
||||
: { ...state.reasoningSignatures, [event.index]: block.signature },
|
||||
},
|
||||
events,
|
||||
]
|
||||
@@ -799,7 +836,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
// Redacted thinking surfaces as an empty reasoning part carrying the opaque
|
||||
// payload as `redactedData` metadata (same model as Vercel's
|
||||
// @ai-sdk/anthropic). The existing content_block_stop closes the part.
|
||||
if (block.type === "redacted_thinking" && block.data) {
|
||||
if (block.type === "redacted_thinking" && block.data !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
@@ -847,18 +884,13 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
}
|
||||
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
const events: LLMEvent[] = []
|
||||
const index = event.index ?? 0
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
anthropicMetadata({ signature: delta.signature }),
|
||||
),
|
||||
reasoningSignatures: { ...state.reasoningSignatures, [index]: delta.signature },
|
||||
},
|
||||
events,
|
||||
NO_EVENTS,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
@@ -889,31 +921,53 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const signature = state.reasoningSignatures[event.index]
|
||||
const lifecycle = resultEvents.length
|
||||
? Lifecycle.stepStart(state.lifecycle, events)
|
||||
: Lifecycle.reasoningEnd(
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
||||
events,
|
||||
`reasoning-${event.index}`,
|
||||
signature === undefined ? undefined : anthropicMetadata({ signature }),
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
const reasoningSignatures = { ...state.reasoningSignatures }
|
||||
delete reasoningSignatures[event.index]
|
||||
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
usage,
|
||||
pendingFinish: {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
},
|
||||
providerMetadata:
|
||||
event.delta?.stop_sequence === null || event.delta?.stop_sequence === undefined
|
||||
? undefined
|
||||
: anthropicMetadata({ stopSequence: event.delta.stop_sequence }),
|
||||
},
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
|
||||
const onMessageStop = (state: ParserState): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(event.delta?.stop_reason),
|
||||
raw: event.delta?.stop_reason ?? undefined,
|
||||
reason: state.pendingFinish?.reason ?? {
|
||||
normalized: "unknown",
|
||||
raw: undefined,
|
||||
},
|
||||
usage,
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
: undefined,
|
||||
usage: state.usage,
|
||||
providerMetadata: state.pendingFinish?.providerMetadata,
|
||||
})
|
||||
return [{ ...state, lifecycle, usage }, events]
|
||||
return [{ ...state, lifecycle }, events]
|
||||
}
|
||||
|
||||
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
|
||||
@@ -938,6 +992,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
|
||||
if (event.type === "error") return onError(event)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
@@ -958,7 +1013,11 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
reasoningSignatures: {},
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
step,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
||||
@@ -289,7 +289,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
})
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
const media: GeminiInlineDataPart[] = []
|
||||
for (const item of content) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { HttpTransport } from "../route/transport"
|
||||
import { Protocol } from "../route/protocol"
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -55,6 +55,9 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
text: Schema.String,
|
||||
})
|
||||
|
||||
export const MessagePhase = Schema.Literals(["commentary", "final_answer"])
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
@@ -86,10 +89,14 @@ const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
||||
])
|
||||
|
||||
const OpenResponsesInputItem = Schema.Union([
|
||||
export const InputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenResponsesInputContent) }),
|
||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenResponsesOutputText) }),
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(OpenResponsesOutputText),
|
||||
phase: Schema.optionalKey(MessagePhase),
|
||||
}),
|
||||
OpenResponsesReasoningItem,
|
||||
OpenResponsesItemReference,
|
||||
Schema.Struct({
|
||||
@@ -104,7 +111,14 @@ const OpenResponsesInputItem = Schema.Union([
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
}),
|
||||
])
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof OpenResponsesInputItem>
|
||||
type OpenResponsesInputItem = Schema.Schema.Type<typeof InputItem>
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| {
|
||||
readonly role: "assistant"
|
||||
readonly content: ReadonlyArray<{ readonly type: "output_text"; readonly text: string }>
|
||||
readonly phase?: MessagePhase | null
|
||||
}
|
||||
|
||||
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
@@ -135,7 +149,7 @@ export const ToolChoice = Schema.Union([
|
||||
// transports in sync without a destructure-and-strip dance.
|
||||
export const coreFields = {
|
||||
model: Schema.String,
|
||||
input: Schema.Array(OpenResponsesInputItem),
|
||||
input: Schema.Array(InputItem),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
tools: optionalArray(Tool),
|
||||
tool_choice: Schema.optional(ToolChoice),
|
||||
@@ -167,7 +181,12 @@ export type OpenResponsesBody = Schema.Schema.Type<typeof OpenResponsesBody>
|
||||
|
||||
const OpenResponsesUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })),
|
||||
input_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
cache_write_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
@@ -201,6 +220,7 @@ export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
delta: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(StreamItem),
|
||||
@@ -233,6 +253,7 @@ export interface Extension {
|
||||
readonly media: ProviderShared.ValidatedMedia
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly messagePhase?: (value: unknown) => MessagePhase | null | undefined
|
||||
}
|
||||
|
||||
const BASE: Extension = { id: ADAPTER, name: NAME }
|
||||
@@ -244,6 +265,9 @@ export interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
}
|
||||
@@ -347,7 +371,7 @@ const lowerUserContent = Effect.fn("OpenResponses.lowerUserContent")(function* (
|
||||
// Tool results may carry structured text, images, and files. Keep media as provider-native
|
||||
// content instead of JSON-stringifying base64 into a prompt string.
|
||||
const lowerToolResultContentItem = Effect.fn("OpenResponses.lowerToolResultContentItem")(function* (
|
||||
item: ToolContent,
|
||||
item: Content,
|
||||
request: LLMRequest,
|
||||
extension: Extension,
|
||||
) {
|
||||
@@ -368,14 +392,14 @@ const lowerToolResultOutput = Effect.fn("OpenResponses.lowerToolResultOutput")(f
|
||||
// compatibility with existing cassettes and provider expectations.
|
||||
if (part.result.type !== "content") return ProviderShared.toolResultText(part)
|
||||
// Preserve the narrowed array element type when compiled through a consumer package.
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension))
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (request: LLMRequest, extension: Extension) {
|
||||
const system: OpenResponsesInputItem[] =
|
||||
const system: LoweredInputItem[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
const input: OpenResponsesInputItem[] = [...system]
|
||||
const input: LoweredInputItem[] = [...system]
|
||||
const store = OpenResponsesOptions.resolve(request).store
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? "openresponses"
|
||||
|
||||
@@ -407,7 +431,27 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
const hostedToolReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
||||
const groups = content.reduce<Array<{ phase: MessagePhase | null | undefined; parts: TextPart[] }>>(
|
||||
(groups, part) => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
const phase =
|
||||
ProviderShared.isRecord(metadata)
|
||||
? messagePhase(metadata.phase, extension)
|
||||
: undefined
|
||||
const group = groups.at(-1)
|
||||
if (group && group.phase === phase) group.parts.push(part)
|
||||
else groups.push({ phase, parts: [part] })
|
||||
return groups
|
||||
},
|
||||
[],
|
||||
)
|
||||
input.push(
|
||||
...groups.map((group) => ({
|
||||
role: "assistant" as const,
|
||||
content: group.parts.map((part) => ({ type: "output_text" as const, text: part.text })),
|
||||
...(group.phase === undefined ? {} : { phase: group.phase }),
|
||||
})),
|
||||
)
|
||||
content.splice(0, content.length)
|
||||
}
|
||||
for (const part of message.content) {
|
||||
@@ -452,7 +496,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, request, extension)),
|
||||
@@ -508,9 +552,9 @@ const lowerOptions = (request: LLMRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
|
||||
export const fromRequestWithExtension = Effect.fn("OpenResponses.fromRequestWithExtension")(function* (
|
||||
request: LLMRequest,
|
||||
extension: Extension = BASE,
|
||||
extension: Extension,
|
||||
) {
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
@@ -536,23 +580,31 @@ export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (
|
||||
}
|
||||
})
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesBody))
|
||||
|
||||
export const fromRequest = Effect.fn("OpenResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
return yield* decodeBody(yield* fromRequestWithExtension(request, BASE))
|
||||
})
|
||||
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
// Responses APIs report `input_tokens` (inclusive total) with a
|
||||
// `cached_tokens` subset, and `output_tokens` (inclusive total) with a
|
||||
// `reasoning_tokens` subset. Pass the totals through and derive the
|
||||
// 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.
|
||||
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
|
||||
const reasoning = usage.output_tokens_details?.reasoning_tokens
|
||||
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, cached)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.input_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
return new Usage({
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.input_tokens, usage.output_tokens, usage.total_tokens),
|
||||
providerMetadata: { [providerMetadataKey]: usage },
|
||||
@@ -588,24 +640,30 @@ const NO_EVENTS: StepResult["1"] = []
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: Event): StepResult => {
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const phase = state.messagePhases[id]
|
||||
const metadata = phase === undefined ? undefined : providerMetadata(state, { phase })
|
||||
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) },
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onOutputTextDone = (state: ParserState, event: Event): StepResult => {
|
||||
const onOutputTextDone = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
if (state.messageItems.has(id)) {
|
||||
if (state.lifecycle.text.has(id) || event.text === undefined) return [state, NO_EVENTS]
|
||||
return onOutputTextDelta(state, { ...event, delta: event.text }, id)
|
||||
}
|
||||
const events: LLMEvent[] = []
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, event.item_id ?? "text-0") }, events]
|
||||
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
|
||||
}
|
||||
|
||||
export const onReasoningDelta = (state: ParserState, event: Event): StepResult => {
|
||||
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const itemID = event.item_id ?? "reasoning-0"
|
||||
const id =
|
||||
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
|
||||
return [
|
||||
@@ -636,6 +694,18 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: (() => {
|
||||
const phase = state.messagePhase(item.phase)
|
||||
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
||||
})(),
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -792,7 +862,28 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
const item = event.item
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) return onOutputTextDone(state, { ...event, item_id: item.id })
|
||||
if (item.type === "message" && item.id) {
|
||||
const itemPhase = state.messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
messageItems.delete(item.id)
|
||||
const { [item.id]: _phase, ...messagePhases } = state.messagePhases
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
item.id,
|
||||
phase === undefined ? undefined : providerMetadata(state, { phase }),
|
||||
),
|
||||
messageItems,
|
||||
messagePhases,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
@@ -892,19 +983,41 @@ const providerError = (state: ParserState, event: Event, fallback: string) => {
|
||||
}
|
||||
|
||||
export const step = (state: ParserState, event: Event) => {
|
||||
if (event.type === "response.output_text.delta") return Effect.succeed(onOutputTextDelta(state, event))
|
||||
if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event))
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta")
|
||||
return Effect.succeed(onReasoningDelta(state, event))
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done")
|
||||
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.output_text.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
: onOutputTextDone(state, event, event.item_id),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_summary_part.done")
|
||||
return Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.added") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return Effect.succeed(onOutputItemAdded(state, event))
|
||||
}
|
||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
if (event.type === "response.output_item.done") {
|
||||
if (event.item?.type === "message" && !event.item.id)
|
||||
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
|
||||
return onOutputItemDone(state, event)
|
||||
}
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||
@@ -926,10 +1039,18 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
messageItems: new Set<string>(),
|
||||
messagePhase: (value) => messagePhase(value, extension),
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown, extension: Extension): MessagePhase | null | undefined => {
|
||||
if (value === "commentary" || value === "final_answer") return value
|
||||
return extension.messagePhase?.(value)
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolContent,
|
||||
} from "../schema"
|
||||
import { classifyProviderFailure } from "../provider-error"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
@@ -131,6 +131,7 @@ const OpenAIChatUsage = Schema.Struct({
|
||||
prompt_tokens_details: optionalNull(
|
||||
Schema.Struct({
|
||||
cached_tokens: Schema.optional(Schema.Number),
|
||||
cache_write_tokens: Schema.optional(Schema.Number),
|
||||
}),
|
||||
),
|
||||
completion_tokens_details: optionalNull(
|
||||
@@ -334,7 +335,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
continue
|
||||
}
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
const content: ReadonlyArray<Tool.Content> = part.result.value
|
||||
const text = content.filter((item) => item.type === "text").map((item) => item.text)
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: text.join("\n") })
|
||||
const files = content.filter((item) => item.type === "file")
|
||||
@@ -453,20 +454,22 @@ const mapFinishReason = (reason: string | null | undefined): FinishReason => {
|
||||
}
|
||||
|
||||
// OpenAI Chat reports `prompt_tokens` (inclusive total) with a
|
||||
// `cached_tokens` subset, and `completion_tokens` (inclusive total) with
|
||||
// a `reasoning_tokens` subset. We pass the inclusive totals through and
|
||||
// derive the non-cached breakdown so the `LLM.Usage` contract is
|
||||
// cached-read and cache-write subsets, and `completion_tokens` (inclusive
|
||||
// total) with a `reasoning_tokens` subset. We pass the inclusive totals
|
||||
// through and derive the non-cached breakdown so the `LLM.Usage` contract is
|
||||
// satisfied on both sides.
|
||||
const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
if (!usage) return undefined
|
||||
const cached = usage.prompt_tokens_details?.cached_tokens
|
||||
const cacheWrite = usage.prompt_tokens_details?.cache_write_tokens
|
||||
const reasoning = usage.completion_tokens_details?.reasoning_tokens
|
||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, cached)
|
||||
const nonCached = ProviderShared.subtractTokens(usage.prompt_tokens, ProviderShared.sumTokens(cached, cacheWrite))
|
||||
return new Usage({
|
||||
inputTokens: usage.prompt_tokens,
|
||||
outputTokens: usage.completion_tokens,
|
||||
nonCachedInputTokens: nonCached,
|
||||
cacheReadInputTokens: cached,
|
||||
cacheWriteInputTokens: cacheWrite,
|
||||
reasoningTokens: reasoning,
|
||||
totalTokens: ProviderShared.totalTokens(usage.prompt_tokens, usage.completion_tokens, usage.total_tokens),
|
||||
providerMetadata: { openai: usage },
|
||||
|
||||
@@ -35,8 +35,18 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
role: Schema.tag("assistant"),
|
||||
content: Schema.Array(Schema.Struct({ type: Schema.tag("output_text"), text: Schema.String })),
|
||||
phase: Schema.optionalKey(Schema.NullOr(OpenResponses.MessagePhase)),
|
||||
}),
|
||||
OpenResponses.InputItem,
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
}
|
||||
@@ -60,6 +70,7 @@ const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIRes
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
messagePhase: (value: unknown) => (value === null ? null : undefined),
|
||||
lowerMedia: ({ part, media, request }) => {
|
||||
if (request.model.provider !== "xai" || media.mime !== "application/pdf") return undefined
|
||||
return {
|
||||
@@ -102,7 +113,7 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
})
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* OpenResponses.fromRequest(
|
||||
const body = yield* OpenResponses.fromRequestWithExtension(
|
||||
LLMRequest.update(request, { tools: [], toolChoice: undefined }),
|
||||
extension,
|
||||
)
|
||||
@@ -208,9 +219,13 @@ const onHostedToolDone = Effect.fn("OpenAIResponses.onHostedToolDone")(function*
|
||||
|
||||
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
|
||||
if (event.type === "response.reasoning_text.delta" || event.type === "response.reasoning_summary.delta")
|
||||
return Effect.succeed(OpenResponses.onReasoningDelta(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.reasoning_text.done" || event.type === "response.reasoning_summary.done")
|
||||
return Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
return event.item_id
|
||||
? Effect.succeed(OpenResponses.onReasoningDone(state, event))
|
||||
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done" && event.item && isHostedToolItem(event.item))
|
||||
return onHostedToolDone(state, event.item)
|
||||
return OpenResponses.step(state, event)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
@@ -9,7 +10,6 @@ import {
|
||||
type ContentPart,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ToolFileContent,
|
||||
type TextPart,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
@@ -206,7 +206,7 @@ export const validateMedia = Effect.fn("ProviderShared.validateMedia")(function*
|
||||
return { mime, base64, dataUrl: `data:${mime};base64,${base64}`, bytes } satisfies ValidatedMedia
|
||||
})
|
||||
|
||||
export const validateToolFile = (route: string, part: ToolFileContent, supportedMimes: ReadonlySet<string>) =>
|
||||
export const validateToolFile = (route: string, part: Tool.FileContent, supportedMimes: ReadonlySet<string>) =>
|
||||
validateMedia(route, { type: "media", mediaType: part.mime, data: part.uri, filename: part.name }, supportedMimes)
|
||||
|
||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||
|
||||
@@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||
return { ...state, stepStarted: true }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
if (state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
if (stepped.text.has(id)) {
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
return stepped
|
||||
}
|
||||
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
||||
events.push(LLMEvent.textStart({ id, providerMetadata }))
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const started = textStart(state, events, id)
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
return started
|
||||
}
|
||||
|
||||
export const reasoningStart = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
|
||||
@@ -412,7 +412,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest) =
|
||||
)
|
||||
|
||||
const generateWith = (stream: Interface["stream"]) =>
|
||||
Effect.fn("LLM.generateTurn")(function* (request: LLMRequest) {
|
||||
Effect.fn("LLM.generate")(function* (request: LLMRequest) {
|
||||
const state = yield* stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce))
|
||||
const response = LLMResponse.complete(state)
|
||||
if (response) return response
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { ModelID, ProviderID, ProviderMetadata, RouteID } from "./ids"
|
||||
|
||||
export const ProviderFailureClassification = Schema.Literal("context-overflow")
|
||||
@@ -152,8 +153,4 @@ export class LLMError extends Schema.TaggedErrorClass<LLMError>()("LLM.Error", {
|
||||
* Anything thrown or yielded by a handler that is not a `ToolFailure` is
|
||||
* treated as a defect and fails the stream.
|
||||
*/
|
||||
export class ToolFailure extends Schema.TaggedErrorClass<ToolFailure>()("LLM.ToolFailure", {
|
||||
message: Schema.String,
|
||||
error: Schema.optional(Schema.Defect()),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
export class ToolFailure extends Tool.Error {}
|
||||
|
||||
@@ -40,9 +40,9 @@ import { ProviderFailureClassification } from "./errors"
|
||||
* - Anthropic and Bedrock report the input breakdown natively: Anthropic's
|
||||
* `input_tokens` and Bedrock's `inputTokens` are non-cached only. Their
|
||||
* mappers sum the breakdown to derive the inclusive `inputTokens`.
|
||||
* Anthropic does *not* break extended-thinking out of `output_tokens`, so
|
||||
* `reasoningTokens` is `undefined` and `outputTokens` carries the
|
||||
* combined total — a documented limitation of the Anthropic API.
|
||||
* Anthropic's `outputTokens` includes extended thinking. Newer responses
|
||||
* expose that subset as `output_tokens_details.thinking_tokens`, which maps
|
||||
* to `reasoningTokens`; older responses leave it undefined.
|
||||
*
|
||||
* `providerMetadata` always carries the provider's raw usage payload —
|
||||
* keyed by provider name (`{ openai: ... }`, `{ anthropic: ... }`, etc.)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import { ToolContent, ToolFileContent, ToolTextContent } from "@opencode-ai/schema/llm"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { JsonSchema, MessageRole, ProviderMetadata } from "./ids"
|
||||
import { CacheHint, CachePolicy, GenerationOptions, HttpOptions, ModelSchema, ProviderOptions } from "./options"
|
||||
import { isRecord } from "../utils/record"
|
||||
@@ -40,8 +40,6 @@ export const MediaPart = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Content.Media" })
|
||||
export type MediaPart = Schema.Schema.Type<typeof MediaPart>
|
||||
|
||||
export { ToolContent, ToolFileContent, ToolTextContent }
|
||||
|
||||
const isToolResultValue = (value: unknown): value is ToolResultValue =>
|
||||
isRecord(value) &&
|
||||
(value.type === "text" || value.type === "json" || value.type === "error" || value.type === "content") &&
|
||||
@@ -63,7 +61,7 @@ export const ToolResultValue = Object.assign(
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("content"),
|
||||
value: Schema.Array(ToolContent),
|
||||
value: Schema.Array(Tool.Content),
|
||||
}),
|
||||
]).annotate({ identifier: "LLM.ToolResult" }),
|
||||
{
|
||||
@@ -79,16 +77,16 @@ export type ToolResultValue = Schema.Schema.Type<typeof ToolResultValue>
|
||||
|
||||
export interface ToolOutput {
|
||||
readonly structured: unknown
|
||||
readonly content: ReadonlyArray<ToolContent>
|
||||
readonly content: ReadonlyArray<Tool.Content>
|
||||
}
|
||||
|
||||
export const ToolOutput = Object.assign(
|
||||
Schema.Struct({
|
||||
structured: Schema.Unknown,
|
||||
content: Schema.Array(ToolContent),
|
||||
content: Schema.Array(Tool.Content),
|
||||
}).annotate({ identifier: "LLM.ToolOutput" }),
|
||||
{
|
||||
make: (structured: unknown, content: ReadonlyArray<ToolContent> = []): ToolOutput => ({ structured, content }),
|
||||
make: (structured: unknown, content: ReadonlyArray<Tool.Content> = []): ToolOutput => ({ structured, content }),
|
||||
fromResultValue: (result: ToolResultValue): ToolOutput | undefined => {
|
||||
switch (result.type) {
|
||||
case "json":
|
||||
|
||||
@@ -251,11 +251,11 @@ export class CacheHint extends Schema.Class<CacheHint>("LLM.CacheHint")({
|
||||
// Auto-placement policy for prompt caching. The protocol-neutral lowering step
|
||||
// reads this and injects `CacheHint`s at the configured boundaries; the
|
||||
// per-protocol body builders then translate those hints into wire markers as
|
||||
// usual. `"auto"` is the recommended default for agent loops — it places one
|
||||
// breakpoint at the last tool definition, one at the last system part, and one
|
||||
// at the latest user message. The combination of provider invalidation
|
||||
// hierarchy (tools → system → messages) and Anthropic/Bedrock's 20-block
|
||||
// lookback means three trailing breakpoints reliably cover the static prefix.
|
||||
// usual. `"auto"` is the recommended default for agent loops — it places
|
||||
// breakpoints at the last tool definition, the first and last distinct system
|
||||
// parts, and the conversation tail. The rolling message breakpoint keeps a
|
||||
// prior cache entry within Anthropic/Bedrock's 20-block lookback during long
|
||||
// tool loops.
|
||||
//
|
||||
// Pass `"none"` to opt out entirely (the legacy behavior). Pass the granular
|
||||
// object form to override individual choices.
|
||||
|
||||
@@ -28,7 +28,7 @@ export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<Dispat
|
||||
|
||||
return decodeAndExecute(tool, call).pipe(
|
||||
Effect.map((value) => result(call, value)),
|
||||
Effect.catchTag("LLM.ToolFailure", (failure) =>
|
||||
Effect.catchTag("Tool.Error", (failure) =>
|
||||
Effect.succeed(result(call, { type: "error", value: failure.message }, failure.error)),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type {
|
||||
ToolCallPart,
|
||||
ToolContent,
|
||||
ToolDefinition as ToolDefinitionClass,
|
||||
ToolOutput as ToolOutputType,
|
||||
} from "./schema"
|
||||
@@ -31,7 +31,7 @@ export interface ToolModelOutputInput<Parameters, Output> {
|
||||
|
||||
export type ToolToModelOutput<Parameters extends ToolSchema<any>, Success extends ToolSchema<any>> = (
|
||||
input: ToolModelOutputInput<Schema.Schema.Type<Parameters>, Success["Encoded"]>,
|
||||
) => ReadonlyArray<ToolContent>
|
||||
) => ReadonlyArray<Tool.Content>
|
||||
|
||||
/**
|
||||
* A type-safe LLM tool. Each tool bundles its own description, parameter
|
||||
@@ -95,7 +95,7 @@ type DynamicToolConfig = {
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function make(config: {
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
}): AnyExecutableTool
|
||||
export function make(config: {
|
||||
@@ -159,7 +159,7 @@ export function make(config: {
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly execute?: undefined
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<ToolContent>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
}): AnyTool
|
||||
export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
@@ -236,7 +236,7 @@ const toJsonSchema = (schema: Schema.Top): JsonSchema.JsonSchema => {
|
||||
}
|
||||
|
||||
const project = (
|
||||
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<ToolContent>) | undefined,
|
||||
toModelOutput: ((input: ToolModelOutputInput<any, any>) => ReadonlyArray<Tool.Content>) | undefined,
|
||||
toStructuredOutput: ((output: unknown) => unknown) | undefined,
|
||||
parameters: unknown,
|
||||
callID: ToolCallPart["id"],
|
||||
|
||||
@@ -39,8 +39,8 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
// No explicit cache field → auto policy fires → last system part + latest
|
||||
// user message both get cache_control markers.
|
||||
// A single system block is both the first and last boundary, so the auto
|
||||
// policy deduplicates it and still marks the conversation tail.
|
||||
expect(prepared.body).toMatchObject({
|
||||
system: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "hi", cache_control: { type: "ephemeral" } }] }],
|
||||
@@ -48,12 +48,15 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("'auto' marks the last tool, last system part, and latest user message on Anthropic", () =>
|
||||
it.effect("'auto' marks the last tool, first and last system parts, and final message boundary on Anthropic", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: anthropicModel,
|
||||
system: "Sys A",
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [
|
||||
Message.user("first user"),
|
||||
@@ -66,7 +69,10 @@ describe("applyCachePolicy", () => {
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
tools: [{ name: "t1", cache_control: { type: "ephemeral" } }],
|
||||
system: [{ type: "text", text: "Sys A", cache_control: { type: "ephemeral" } }],
|
||||
system: [
|
||||
{ type: "text", text: "Base agent", cache_control: { type: "ephemeral" } },
|
||||
{ type: "text", text: "Project instructions", cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "first user" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "assistant reply" }] },
|
||||
@@ -120,7 +126,10 @@ describe("applyCachePolicy", () => {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: bedrockModel,
|
||||
system: "Sys",
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
messages: [Message.user("first user"), Message.assistant("reply"), Message.user("latest user")],
|
||||
cache: "auto",
|
||||
@@ -131,7 +140,12 @@ describe("applyCachePolicy", () => {
|
||||
toolConfig: {
|
||||
tools: [{ toolSpec: { name: "t1" } }, { cachePoint: { type: "default" } }],
|
||||
},
|
||||
system: [{ text: "Sys" }, { cachePoint: { type: "default" } }],
|
||||
system: [
|
||||
{ text: "Base agent" },
|
||||
{ cachePoint: { type: "default" } },
|
||||
{ text: "Project instructions" },
|
||||
{ cachePoint: { type: "default" } },
|
||||
],
|
||||
messages: [
|
||||
{ role: "user", content: [{ text: "first user" }] },
|
||||
{ role: "assistant", content: [{ text: "reply" }] },
|
||||
@@ -193,9 +207,55 @@ describe("applyCachePolicy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const body = prepared.body as { system: Array<{ text: string; cache_control?: unknown }> }
|
||||
const body = prepared.body as {
|
||||
system: Array<{ text: string; cache_control?: unknown }>
|
||||
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
|
||||
}
|
||||
expect(body.system[0]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
|
||||
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("auto policy stays within the four-breakpoint cap when preserving manual hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: anthropicModel,
|
||||
system: [
|
||||
{ type: "text", text: "Base agent" },
|
||||
{
|
||||
type: "text",
|
||||
text: "Manual context",
|
||||
cache: new CacheHint({ type: "ephemeral", ttlSeconds: 3600 }),
|
||||
},
|
||||
{ type: "text", text: "Project instructions" },
|
||||
],
|
||||
tools: [{ name: "t1", description: "t1", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "hi",
|
||||
cache: "auto",
|
||||
})
|
||||
const applied = applyCachePolicy(request)
|
||||
expect(applied.tools[0]?.cache).toBeDefined()
|
||||
expect(applied.system.map((part) => part.cache !== undefined)).toEqual([true, true, true])
|
||||
const tail = applied.messages[0]!.content[0]!
|
||||
expect("cache" in tail ? tail.cache : undefined).toBeUndefined()
|
||||
expect(applyCachePolicy(applied)).toBe(applied)
|
||||
|
||||
const prepared = yield* LLMClient.prepare(request)
|
||||
|
||||
const body = prepared.body as {
|
||||
tools: Array<{ cache_control?: unknown }>
|
||||
system: Array<{ cache_control?: unknown }>
|
||||
messages: Array<{ content: Array<{ cache_control?: unknown }> }>
|
||||
}
|
||||
const marked = [
|
||||
...body.tools.map((tool) => tool.cache_control),
|
||||
...body.system.map((part) => part.cache_control),
|
||||
...body.messages.flatMap((message) => message.content.map((part) => part.cache_control)),
|
||||
].filter((cache) => cache !== undefined)
|
||||
expect(marked).toHaveLength(4)
|
||||
expect(body.system[1]?.cache_control).toEqual({ type: "ephemeral", ttl: "1h" })
|
||||
expect(body.messages[0]?.content[0]?.cache_control).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -23,10 +23,6 @@ import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages
|
||||
describe("public exports", () => {
|
||||
test("root exposes app-facing runtime APIs", () => {
|
||||
expect(LLM.request).toBeFunction()
|
||||
expect(LLM.generateTurn).toBeFunction()
|
||||
expect(LLM.streamTurn).toBeFunction()
|
||||
expect(LLM).not.toHaveProperty("generate")
|
||||
expect(LLM).not.toHaveProperty("stream")
|
||||
expect(LLMClient.Service).toBeFunction()
|
||||
expect(LLMClient.layer).toBeDefined()
|
||||
expect(ImageInput.bytes).toBeFunction()
|
||||
|
||||
+54
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM } from "../../src"
|
||||
import { CacheHint, LLM, LLMRequest, Message, ToolCallPart, ToolDefinition } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as Anthropic from "../../src/providers/anthropic"
|
||||
import { LARGE_CACHEABLE_SYSTEM } from "../recorded-scenarios"
|
||||
@@ -24,6 +24,39 @@ const cacheRequest = LLM.request({
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const lookup = ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description: "Look up a fixture value.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { index: { type: "number" } },
|
||||
required: ["index"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
const longToolTurn = [
|
||||
Message.user("Run the fixture lookups."),
|
||||
...Array.from({ length: 11 }, (_, index) => {
|
||||
const id = `lookup_${index}`
|
||||
return [
|
||||
Message.assistant(ToolCallPart.make({ id, name: lookup.name, input: { index } })),
|
||||
Message.tool({
|
||||
id,
|
||||
name: lookup.name,
|
||||
result: `Fixture result ${index}. `.repeat(80),
|
||||
}),
|
||||
]
|
||||
}).flat(),
|
||||
]
|
||||
const longToolTurnRequest = LLM.request({
|
||||
id: "recorded_anthropic_cache_long_tool_turn",
|
||||
model,
|
||||
system: LARGE_CACHEABLE_SYSTEM,
|
||||
messages: longToolTurn,
|
||||
tools: [lookup],
|
||||
generation: { maxTokens: 16, temperature: 0 },
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "anthropic-messages-cache",
|
||||
provider: "anthropic",
|
||||
@@ -50,4 +83,28 @@ describe("Anthropic Messages cache recorded", () => {
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect.with("keeps a long tool turn inside the cache lookback", { tags: ["cache", "tool"] }, () =>
|
||||
Effect.gen(function* () {
|
||||
const first = yield* LLMClient.generate(longToolTurnRequest)
|
||||
const firstRead = first.usage?.cacheReadInputTokens ?? 0
|
||||
const firstWrite = first.usage?.cacheWriteInputTokens ?? 0
|
||||
const firstCached = firstRead + firstWrite
|
||||
// The prefix may already be warm when recording, so either a read or a
|
||||
// write establishes that Anthropic recognized the cache boundary.
|
||||
expect(firstCached).toBeGreaterThan(0)
|
||||
|
||||
const second = yield* LLMClient.generate(
|
||||
LLMRequest.update(longToolTurnRequest, {
|
||||
messages: [
|
||||
...longToolTurn,
|
||||
Message.assistant("The fixture lookups are complete."),
|
||||
Message.user("Reply exactly: OK"),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(second.usage?.cacheReadInputTokens ?? 0).toBeGreaterThanOrEqual(firstCached)
|
||||
expect(second.usage?.cacheWriteInputTokens ?? 0).toBeLessThan(firstCached)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -506,6 +506,7 @@ describe("Anthropic Messages route", () => {
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
expect(response.events.find((event) => event.type === "reasoning-delta" && event.text === "")).toBeUndefined()
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "text", text: "Hello!" },
|
||||
{ type: "reasoning", text: "thinking", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
@@ -518,6 +519,199 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requires message_stop before completing a streamed message", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidProviderOutput",
|
||||
message: "Provider stream ended without a terminal finish event",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps thinking tokens and preserves unknown Anthropic usage fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
usage: {
|
||||
input_tokens: 5,
|
||||
cache_read_input_tokens: 2,
|
||||
service_tier: "standard",
|
||||
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||
server_tool_use: { web_search_requests: 1, start_counter: 2 },
|
||||
output_tokens_details: { thinking_tokens: 3, start_detail: "preserved" },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: {
|
||||
output_tokens: 8,
|
||||
server_tool_use: { web_search_requests: 2, terminal_counter: 3 },
|
||||
output_tokens_details: { terminal_detail: "preserved" },
|
||||
future_terminal: { requests: 4 },
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.usage).toMatchObject({
|
||||
inputTokens: 7,
|
||||
outputTokens: 8,
|
||||
reasoningTokens: 3,
|
||||
totalTokens: 15,
|
||||
providerMetadata: {
|
||||
anthropic: {
|
||||
input_tokens: 5,
|
||||
cache_read_input_tokens: 2,
|
||||
service_tier: "standard",
|
||||
cache_creation: { ephemeral_5m_input_tokens: 1 },
|
||||
server_tool_use: { web_search_requests: 2, start_counter: 2, terminal_counter: 3 },
|
||||
output_tokens: 8,
|
||||
output_tokens_details: {
|
||||
thinking_tokens: 3,
|
||||
start_detail: "preserved",
|
||||
terminal_detail: "preserved",
|
||||
},
|
||||
future_terminal: { requests: 4 },
|
||||
},
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("round-trips omitted thinking carried only by a signature delta", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "thinking", thinking: "", signature: "" },
|
||||
},
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "sig_1" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({ model, messages: [response.message], cache: "none" }),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: [{ type: "thinking", thinking: "", signature: "sig_1" }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains a thinking signature supplied in content_block_start", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "thinking", thinking: "", signature: "sig_1" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { anthropic: { signature: "sig_1" } } },
|
||||
])
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { anthropic: { signature: "sig_1" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains complete tool input from content_block_start", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
{ id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains empty text blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([{ type: "text", text: "" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses redacted thinking into empty reasoning with redactedData metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
@@ -629,6 +823,7 @@ describe("Anthropic Messages route", () => {
|
||||
delta: { stop_reason: "model_context_window_exceeded" },
|
||||
usage: { output_tokens: 1 },
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -646,6 +841,7 @@ describe("Anthropic Messages route", () => {
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{ type: "message_delta", delta: { stop_reason: "pause_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -664,6 +860,7 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "input_json_delta", partial_json: ':"weather"}' } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
@@ -849,6 +1046,7 @@ describe("Anthropic Messages route", () => {
|
||||
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Found it." } },
|
||||
{ type: "content_block_stop", index: 2 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
@@ -912,6 +1110,7 @@ describe("Anthropic Messages route", () => {
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
|
||||
@@ -939,6 +939,7 @@ describe("Bedrock Converse route", () => {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
|
||||
Message.tool({
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("Cloudflare", () => {
|
||||
|
||||
it.effect("posts to the derived gateway endpoint with bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLM.generateTurn(
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: CloudflareAIGateway.configure({
|
||||
accountId: "test-account",
|
||||
@@ -104,7 +104,7 @@ describe("Cloudflare", () => {
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
const response = yield* LLM.generateTurn(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(
|
||||
@@ -150,7 +150,7 @@ describe("Cloudflare", () => {
|
||||
|
||||
it.effect("supports authenticated AI Gateway plus upstream provider auth", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* LLM.generateTurn(
|
||||
yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: CloudflareAIGateway.configure({
|
||||
accountId: "test-account",
|
||||
@@ -221,7 +221,7 @@ describe("Cloudflare", () => {
|
||||
|
||||
it.effect("posts direct Workers AI requests to the account endpoint with bearer auth", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLM.generateTurn(
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: CloudflareWorkersAI.configure({
|
||||
accountId: "test-account",
|
||||
@@ -256,7 +256,7 @@ describe("Cloudflare", () => {
|
||||
|
||||
it.effect("supports direct Workers AI token aliases through auth config", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* LLM.generateTurn(
|
||||
yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: CloudflareWorkersAI.configure({
|
||||
accountId: "test-account",
|
||||
|
||||
@@ -550,7 +550,7 @@ describe("OpenAI Chat route", () => {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
}),
|
||||
)
|
||||
@@ -558,8 +558,9 @@ describe("OpenAI Chat route", () => {
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
nonCachedInputTokens: 2,
|
||||
cacheReadInputTokens: 1,
|
||||
cacheWriteInputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
@@ -567,7 +568,7 @@ describe("OpenAI Chat route", () => {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
prompt_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import { LLM, LLMEvent, Message } from "../../src"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses"
|
||||
@@ -70,6 +70,31 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits OpenAI-only nullable phases from the Open Responses baseline", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://responses.example.test/v1",
|
||||
}).model("example-model")
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openresponses: { phase: null } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
input: [{ role: "assistant", content: [{ type: "output_text", text: "Unclassified." }] }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads standard options from the Open Responses namespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = configure({
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("OpenAI Responses image generation recorded", () => {
|
||||
partialImages: 0,
|
||||
}),
|
||||
]
|
||||
const response = yield* LLM.generateTurn(
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: openai.responses("gpt-5-mini"),
|
||||
messages: [initial],
|
||||
@@ -49,7 +49,7 @@ describe("OpenAI Responses image generation recorded", () => {
|
||||
expect(result.result.value[0].mime).toBe("image/jpeg")
|
||||
expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true)
|
||||
|
||||
const edited = yield* LLM.generateTurn(
|
||||
const edited = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: openai.responses("gpt-5-mini"),
|
||||
messages: [initial, response.message, Message.user("Now make the triangle blue.")],
|
||||
|
||||
@@ -832,7 +832,7 @@ describe("OpenAI Responses route", () => {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
@@ -842,8 +842,9 @@ describe("OpenAI Responses route", () => {
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
nonCachedInputTokens: 2,
|
||||
cacheReadInputTokens: 1,
|
||||
cacheWriteInputTokens: 2,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
@@ -851,7 +852,7 @@ describe("OpenAI Responses route", () => {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
@@ -881,6 +882,121 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves and replays assistant message phases", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_commentary" },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking." },
|
||||
{ type: "response.output_text.done", item_id: "msg_commentary" },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_commentary", phase: "commentary" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.output_text.done", item_id: "msg_final", text: "Finished." },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "message", id: "msg_final", phase: "final_answer" },
|
||||
},
|
||||
{ type: "response.output_item.added", item: { type: "message", id: "msg_null", phase: null } },
|
||||
{ type: "response.output_text.delta", item_id: "msg_null", delta: "Unclassified." },
|
||||
{ type: "response.output_item.done", item: { type: "message", id: "msg_null", phase: null } },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{
|
||||
type: "text",
|
||||
text: "Checking.",
|
||||
providerMetadata: { openai: { phase: "commentary" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Finished.",
|
||||
providerMetadata: { openai: { phase: "final_answer" } },
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Unclassified.",
|
||||
providerMetadata: { openai: { phase: null } },
|
||||
},
|
||||
])
|
||||
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Checking." }],
|
||||
phase: "commentary",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Finished." }],
|
||||
phase: "final_answer",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Unclassified." }],
|
||||
phase: null,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects output text events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_text.delta", delta: "orphaned" },
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("response.output_text.delta is missing item_id")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reasoning events without the spec-required item id", () =>
|
||||
Effect.gen(function* () {
|
||||
const events = [
|
||||
{ type: "response.reasoning_summary_part.added", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.done", summary_index: 0 },
|
||||
{ type: "response.reasoning_text.done" },
|
||||
]
|
||||
|
||||
for (const event of events) {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(sseEvents(event, { type: "response.completed", response: { id: "resp_1" } })),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain(`${event.type} is missing item_id`)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps incomplete response reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const generate = (incompleteDetails: object) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Content } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import {
|
||||
GenerationOptions,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
ToolChoice,
|
||||
ToolContent,
|
||||
ToolOutput,
|
||||
toDefinitions,
|
||||
} from "../src"
|
||||
@@ -279,7 +279,7 @@ describe("LLMClient tools", () => {
|
||||
|
||||
it.effect("models canonical tool files with URIs", () =>
|
||||
Effect.sync(() => {
|
||||
const decode = Schema.decodeUnknownSync(ToolContent)
|
||||
const decode = Schema.decodeUnknownSync(Content)
|
||||
|
||||
expect(decode({ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" })).toEqual({
|
||||
type: "file",
|
||||
@@ -539,6 +539,7 @@ describe("LLMClient tools", () => {
|
||||
},
|
||||
{ type: "content_block_stop", index: 1 },
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 5 } },
|
||||
{ type: "message_stop" },
|
||||
)
|
||||
: sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
@@ -546,6 +547,7 @@ describe("LLMClient tools", () => {
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Done." } },
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
@@ -801,6 +803,7 @@ describe("LLMClient tools", () => {
|
||||
{ type: "content_block_delta", index: 2, delta: { type: "text_delta", text: "Done." } },
|
||||
{ type: "content_block_stop", index: 2 },
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 8 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
|
||||
@@ -32,9 +32,9 @@ Tool.make({
|
||||
],
|
||||
})
|
||||
|
||||
LLM.streamTurn(request)
|
||||
LLM.generateTurn(LLMRequest.update(request, { tools: toDefinitions({ schemaOnly }) }))
|
||||
LLM.stream(request)
|
||||
LLM.generate(LLMRequest.update(request, { tools: toDefinitions({ schemaOnly }) }))
|
||||
ToolRuntime.dispatch({ executable }, { type: "tool-call", id: "call_1", name: "executable", input: { city: "Paris" } })
|
||||
|
||||
// @ts-expect-error High-level tool orchestration overloads are intentionally not supported.
|
||||
LLM.streamTurn({ request, tools: { schemaOnly } })
|
||||
LLM.stream({ request, tools: { schemaOnly } })
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/sdk": "1.18.5",
|
||||
"@opencode-ai/session-ui": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@pierre/trees": "1.0.0-beta.4",
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
@@ -171,7 +171,7 @@ export function applyDirectoryEvent(input: {
|
||||
break
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
const props = event.properties as { sessionID: string; diff: SessionDiff[] }
|
||||
input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" }))
|
||||
break
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
@@ -32,7 +32,7 @@ describe("app session cache", () => {
|
||||
test("dropSessionCaches clears orphaned parts without message rows", () => {
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SessionDiff[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
@@ -63,7 +63,7 @@ describe("app session cache", () => {
|
||||
const m = msg("msg_1", "ses_1")
|
||||
const store: {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SessionDiff[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
|
||||
@@ -4,14 +4,14 @@ import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
type SessionCache = {
|
||||
session_status: Record<string, SessionStatus | undefined>
|
||||
session_diff: Record<string, FileDiffInfo[] | undefined>
|
||||
session_diff: Record<string, SessionDiff[] | undefined>
|
||||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
|
||||
@@ -13,9 +13,9 @@ import type {
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
@@ -50,7 +50,7 @@ export type State = {
|
||||
}
|
||||
session_working(id: string): boolean
|
||||
session_diff: {
|
||||
[sessionID: string]: FileDiffInfo[]
|
||||
[sessionID: string]: SessionDiff[]
|
||||
}
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
|
||||
@@ -25,7 +25,7 @@ describe("adaptServerEvent", () => {
|
||||
} as OpenCodeEvent
|
||||
|
||||
expect(adaptServerEvent(current)).toMatchObject({
|
||||
type: "permission.asked",
|
||||
type: "permission.v2.asked",
|
||||
properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] },
|
||||
current,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import type { Event } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Event, PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -18,7 +18,15 @@ const isAbortError = (error: unknown) =>
|
||||
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
|
||||
|
||||
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
|
||||
export type ServerEvent = Event & { current?: OpenCodeEvent }
|
||||
type PermissionEvent = {
|
||||
id: string
|
||||
type: "permission.v2.asked"
|
||||
properties: PermissionRequest
|
||||
current?: OpenCodeEvent
|
||||
}
|
||||
export type ServerEvent = (Event | PermissionEvent) & {
|
||||
current?: OpenCodeEvent
|
||||
}
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
@@ -29,7 +37,7 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
if (event.type === "permission.v2.asked") {
|
||||
return {
|
||||
id: event.id,
|
||||
type: "permission.asked",
|
||||
type: "permission.v2.asked",
|
||||
properties: {
|
||||
id: event.data.id,
|
||||
sessionID: event.data.sessionID,
|
||||
@@ -43,16 +51,16 @@ export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
: undefined,
|
||||
},
|
||||
current: event,
|
||||
} as ServerEvent
|
||||
}
|
||||
}
|
||||
if (event.type === "permission.v2.replied")
|
||||
return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: "permission.v2.replied", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.asked")
|
||||
return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: "question.v2.asked", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.replied")
|
||||
return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: "question.v2.replied", properties: event.data, current: event } as ServerEvent
|
||||
if (event.type === "question.v2.rejected")
|
||||
return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: "question.v2.rejected", properties: event.data, current: event } as ServerEvent
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
FileDiffInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||
@@ -140,7 +140,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
const [data, setData] = createStore({
|
||||
info: {} as Record<string, Session | undefined>,
|
||||
session_status: {} as Record<string, SessionStatus>,
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
session_diff: {} as Record<string, SessionDiff[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
@@ -773,7 +773,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
return
|
||||
}
|
||||
case "session.diff": {
|
||||
const props = event.properties as { sessionID: string; diff: FileDiffInfo[] }
|
||||
const props = event.properties as { sessionID: string; diff: SessionDiff[] }
|
||||
setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" }))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -909,13 +909,8 @@ export default function Page() {
|
||||
)
|
||||
|
||||
const stopVcs = sdk().event.listen((evt) => {
|
||||
if (evt.details.type !== "filesystem.changed") return
|
||||
const props =
|
||||
typeof evt.details.properties === "object" && evt.details.properties
|
||||
? (evt.details.properties as Record<string, unknown>)
|
||||
: undefined
|
||||
const file = typeof props?.file === "string" ? props.file : undefined
|
||||
if (!file || file.startsWith(".git/")) return
|
||||
if (evt.details.type !== "file.watcher.updated") return
|
||||
if (evt.details.properties.file.startsWith(".git/")) return
|
||||
refreshVcs()
|
||||
})
|
||||
onCleanup(stopVcs)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createEffect, onCleanup, type JSX } from "solid-js"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import { SessionReview } from "@opencode-ai/session-ui/session-review"
|
||||
import type {
|
||||
SessionReviewCommentActions,
|
||||
@@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments"
|
||||
|
||||
export type DiffStyle = "unified" | "split"
|
||||
|
||||
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||
type ReviewDiff = SessionDiff | VcsFileDiff
|
||||
|
||||
export interface SessionReviewTabProps {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -23,7 +23,8 @@ import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
@@ -59,7 +60,7 @@ import type { RenderDiff } from "@/pages/session/v2/review-diff-kinds"
|
||||
|
||||
export function SessionSidePanel(props: {
|
||||
canReview: () => boolean
|
||||
diffs: () => (FileDiffInfo | VcsFileDiff)[]
|
||||
diffs: () => (SessionDiff | VcsFileDiff)[]
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import type { Kind } from "@/components/file-tree-v2"
|
||||
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
|
||||
export type RenderDiff = FileDiffInfo | VcsFileDiff
|
||||
export type RenderDiff = SessionDiff | VcsFileDiff
|
||||
|
||||
export function normalizePath(p: string) {
|
||||
return normalizeFileTreeV2Path(p)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js"
|
||||
import type { FileDiffInfo, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionDiff } from "@/utils/diffs"
|
||||
import {
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX,
|
||||
SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN,
|
||||
@@ -29,7 +30,7 @@ import {
|
||||
import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
|
||||
|
||||
type ReviewDiff = FileDiffInfo | VcsFileDiff
|
||||
type ReviewDiff = SessionDiff | VcsFileDiff
|
||||
|
||||
export type ReviewPanelV2Props = {
|
||||
title?: JSX.Element
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
import { diffs, message } from "./diffs"
|
||||
|
||||
@@ -9,7 +9,7 @@ const item = {
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
} satisfies FileDiffInfo
|
||||
} satisfies SnapshotFileDiff
|
||||
|
||||
describe("diffs", () => {
|
||||
test("keeps valid arrays", () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { FileDiffInfo } from "@opencode-ai/sdk/v2"
|
||||
import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Message } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
type Diff = FileDiffInfo
|
||||
export type SessionDiff = SnapshotFileDiff & { file: string; patch: string }
|
||||
|
||||
function diff(value: unknown): value is Diff {
|
||||
function diff(value: unknown): value is SessionDiff {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false
|
||||
if (!("file" in value) || typeof value.file !== "string") return false
|
||||
if (!("patch" in value) || typeof value.patch !== "string") return false
|
||||
@@ -17,7 +17,7 @@ function object(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function diffs(value: unknown): Diff[] {
|
||||
export function diffs(value: unknown): SessionDiff[] {
|
||||
if (Array.isArray(value) && value.every(diff)) return value
|
||||
if (Array.isArray(value)) return value.filter(diff)
|
||||
if (diff(value)) return [value]
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function streamTurn(input: {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error("event stream disconnected during prompt execution")
|
||||
const event = next.value
|
||||
if (event.type === "permission.v2.asked" && event.data.sessionID === input.sessionID) {
|
||||
if (event.type === "permission.asked" && event.data.sessionID === input.sessionID) {
|
||||
const tool = event.data.source?.callID ? tools.get(event.data.source.callID) : undefined
|
||||
await replyPermission({
|
||||
client: input.client,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Result } from "effect"
|
||||
import { isAbsolute, resolve } from "node:path"
|
||||
import { pendingToolCall, stringValue, toLocations, toToolKind, type ToolInput } from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.v2.asked" }>
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type Connection = Pick<AgentSideConnection, "requestPermission"> & Partial<Pick<AgentSideConnection, "writeTextFile">>
|
||||
type Tool = { readonly name: string; readonly input: ToolInput }
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ import {
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin/v2/effect"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/effect/tool"
|
||||
} from "@opencode-ai/plugin/effect"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.effect")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
@@ -24,5 +24,5 @@ const key = Symbol.for("opencode.plugin.v2.effect")
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
Tool,
|
||||
Tool: { Error: Tool.Error },
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
} from "@opencode-ai/plugin/v2"
|
||||
import { Tool } from "@opencode-ai/plugin/v2/tool"
|
||||
} from "@opencode-ai/plugin"
|
||||
|
||||
const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
;(globalThis as typeof globalThis & { [key]?: unknown })[key] = {
|
||||
@@ -24,5 +23,4 @@ const key = Symbol.for("opencode.plugin.v2.promise")
|
||||
Provider,
|
||||
Reference,
|
||||
Skill,
|
||||
Tool,
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
||||
}
|
||||
const event = next.value
|
||||
|
||||
if (event.type === "permission.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
if (event.type === "permission.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
await replyPermission(event.data)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -13,11 +13,13 @@ type Options = {
|
||||
readonly command?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
const startupDirectory = process.cwd()
|
||||
|
||||
function command(password: string, options: Options) {
|
||||
const [executable, ...args] = options.command ?? [...selfCommand(), "serve"]
|
||||
if (!executable) throw new Error("Failed to resolve standalone server command")
|
||||
return ChildProcess.make(executable, [...args, "--stdio", "--port", "0"], {
|
||||
cwd: process.cwd(),
|
||||
cwd: startupDirectory,
|
||||
// Explicit entry wins over anything inherited, so a user-exported
|
||||
// OPENCODE_PASSWORD cannot shadow the child's lease credential.
|
||||
env: { OPENCODE_PASSWORD: password },
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import path from "node:path"
|
||||
|
||||
const entrypoint = process.argv[1] ? path.resolve(process.argv[1]) : undefined
|
||||
|
||||
export function selfCommand() {
|
||||
const runtime = path.basename(process.execPath, path.extname(process.execPath)).toLowerCase()
|
||||
if (runtime !== "bun" && runtime !== "node" && runtime !== "nodejs") return [process.execPath]
|
||||
if (!process.argv[1]) throw new Error("Failed to resolve CLI entrypoint")
|
||||
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), process.argv[1]]
|
||||
return [process.execPath, process.argv[1]]
|
||||
if (!entrypoint) throw new Error("Failed to resolve CLI entrypoint")
|
||||
if (runtime === "node" || runtime === "nodejs") return [process.execPath, ...nodeFlags(), entrypoint]
|
||||
return [process.execPath, entrypoint]
|
||||
}
|
||||
|
||||
function nodeFlags() {
|
||||
|
||||
@@ -502,7 +502,7 @@ function permissionAsked(
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly callID: string }
|
||||
} = {},
|
||||
) {
|
||||
return ephemeralEvent("permission.v2.asked", {
|
||||
return ephemeralEvent("permission.asked", {
|
||||
id,
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Service } from "@opencode-ai/client/effect/service"
|
||||
import path from "node:path"
|
||||
import { Standalone } from "../../src/services/standalone"
|
||||
|
||||
process.argv[1] = path.join(import.meta.dir, "../../src/index.ts")
|
||||
process.chdir(path.join(import.meta.dir, "../../../.."))
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Standalone.start()
|
||||
const endpoint = yield* Standalone.start({
|
||||
command: [process.execPath, path.join(import.meta.dir, "../../src/index.ts"), "serve"],
|
||||
})
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", endpoint.url), { headers: Service.headers(endpoint) }),
|
||||
)
|
||||
|
||||
@@ -75,20 +75,11 @@ export const define = sdk.Plugin.define`
|
||||
const effectPluginModule = promisePluginModule
|
||||
.replace("opencode.plugin.v2.promise", "opencode.plugin.v2.effect")
|
||||
.replace("Promise plugin", "Effect plugin")
|
||||
const promiseToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
|
||||
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
|
||||
export const Tool = sdk.Tool
|
||||
export const make = sdk.Tool.make`
|
||||
const promiseToolModule = `export {}`
|
||||
const effectToolModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.effect")]
|
||||
if (!sdk) throw new Error("OpenCode Effect plugin SDK is unavailable")
|
||||
export const Tool = sdk.Tool
|
||||
export const Failure = sdk.Tool.Failure
|
||||
export const RegistrationError = sdk.Tool.RegistrationError
|
||||
export const make = sdk.Tool.make
|
||||
export const validateName = sdk.Tool.validateName
|
||||
export const registrationEntries = sdk.Tool.registrationEntries
|
||||
export const validateNamespace = sdk.Tool.validateNamespace
|
||||
export const toLLMDefinition = sdk.Tool.toLLMDefinition`
|
||||
export const Error = sdk.Tool.Error
|
||||
`
|
||||
return `#!/usr/bin/env -S node ${nodeExecArgv.join(" ")}
|
||||
import __cjs_mod__ from "node:module"
|
||||
import { chmodSync as __ocChmod, existsSync as __ocExists, lstatSync as __ocLstat, mkdirSync as __ocMkdir, renameSync as __ocRename, rmSync as __ocRm, writeFileSync as __ocWrite } from "node:fs"
|
||||
@@ -100,17 +91,17 @@ const __filename = import.meta.filename
|
||||
const __dirname = import.meta.dirname
|
||||
const require = __cjs_mod__.createRequire(import.meta.url)
|
||||
const __ocPluginModules = ${JSON.stringify({
|
||||
"@opencode-ai/plugin/v2": "opencode:plugin-v2",
|
||||
"@opencode-ai/plugin/v2/plugin": "opencode:plugin-v2-plugin",
|
||||
"@opencode-ai/plugin/v2/tool": "opencode:plugin-v2-tool",
|
||||
"@opencode-ai/plugin/v2/effect": "opencode:plugin-v2-effect",
|
||||
"@opencode-ai/plugin/v2/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
||||
"@opencode-ai/plugin/v2/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||
"@opencode-ai/plugin": "opencode:plugin-v2",
|
||||
"@opencode-ai/plugin/promise/plugin": "opencode:plugin-promise-plugin",
|
||||
"@opencode-ai/plugin/promise/tool": "opencode:plugin-promise-tool",
|
||||
"@opencode-ai/plugin/effect": "opencode:plugin-v2-effect",
|
||||
"@opencode-ai/plugin/effect/plugin": "opencode:plugin-v2-effect-plugin",
|
||||
"@opencode-ai/plugin/effect/tool": "opencode:plugin-v2-effect-tool",
|
||||
})}
|
||||
const __ocPluginSources = ${JSON.stringify({
|
||||
"opencode:plugin-v2": promiseModule,
|
||||
"opencode:plugin-v2-plugin": promisePluginModule,
|
||||
"opencode:plugin-v2-tool": promiseToolModule,
|
||||
"opencode:plugin-promise-plugin": promisePluginModule,
|
||||
"opencode:plugin-promise-tool": promiseToolModule,
|
||||
"opencode:plugin-v2-effect": effectModule,
|
||||
"opencode:plugin-v2-effect-plugin": effectPluginModule,
|
||||
"opencode:plugin-v2-effect-tool": effectToolModule,
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"@opencode-ai/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.98"
|
||||
"effect": "4.0.0-beta.101"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
|
||||
@@ -6,11 +6,86 @@ import {
|
||||
groupNames,
|
||||
promiseOmitEndpoints,
|
||||
} from "@opencode-ai/protocol/client"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { Credential } from "@opencode-ai/schema/credential"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { InstructionEntry } from "@opencode-ai/schema/instruction-entry"
|
||||
import { Integration } from "@opencode-ai/schema/integration"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Mcp } from "@opencode-ai/schema/mcp"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { PermissionSaved } from "@opencode-ai/schema/permission-saved"
|
||||
import { Plugin } from "@opencode-ai/schema/plugin"
|
||||
import { Project } from "@opencode-ai/schema/project"
|
||||
import { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
import { AgentAttachment, FileAttachment, Prompt, PromptMention } from "@opencode-ai/schema/prompt"
|
||||
import { PromptInput } from "@opencode-ai/schema/prompt-input"
|
||||
import { Provider } from "@opencode-ai/schema/provider"
|
||||
import { Pty } from "@opencode-ai/schema/pty"
|
||||
import { PtyTicket } from "@opencode-ai/schema/pty-ticket"
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { Reference } from "@opencode-ai/schema/reference"
|
||||
import { AbsolutePath, PositiveInt, RelativePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
import { Shell } from "@opencode-ai/schema/shell"
|
||||
import { Skill } from "@opencode-ai/schema/skill"
|
||||
import { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
const promiseContract = compile(ClientApi, { groupNames, omitEndpoints: promiseOmitEndpoints })
|
||||
const effectContract = compile(ClientApi, { groupNames, omitEndpoints: effectOmitEndpoints })
|
||||
const effectTypeReferences = [
|
||||
...namespaceTypes("Agent", "@opencode-ai/schema/agent", Agent),
|
||||
...namespaceTypes("Command", "@opencode-ai/schema/command", Command),
|
||||
...namespaceTypes("Credential", "@opencode-ai/schema/credential", Credential),
|
||||
...namespaceTypes("Event", "@opencode-ai/schema/event", Event),
|
||||
...namespaceTypes("EventLog", "@opencode-ai/schema/event-log", EventLog),
|
||||
...namespaceTypes("FileDiff", "@opencode-ai/schema/file-diff", FileDiff),
|
||||
...namespaceTypes("FileSystem", "@opencode-ai/schema/filesystem", FileSystem),
|
||||
...namespaceTypes("Form", "@opencode-ai/schema/form", Form),
|
||||
...namespaceTypes("InstructionEntry", "@opencode-ai/schema/instruction-entry", InstructionEntry),
|
||||
...namespaceTypes("Integration", "@opencode-ai/schema/integration", Integration),
|
||||
...namespaceTypes("Location", "@opencode-ai/schema/location", Location),
|
||||
...namespaceTypes("Mcp", "@opencode-ai/schema/mcp", Mcp),
|
||||
...namespaceTypes("Model", "@opencode-ai/schema/model", Model),
|
||||
...namespaceTypes("Permission", "@opencode-ai/schema/permission", Permission),
|
||||
...namespaceTypes("PermissionSaved", "@opencode-ai/schema/permission-saved", PermissionSaved),
|
||||
...namespaceTypes("Plugin", "@opencode-ai/schema/plugin", Plugin),
|
||||
...namespaceTypes("Project", "@opencode-ai/schema/project", Project),
|
||||
...namespaceTypes("ProjectCopy", "@opencode-ai/schema/project-copy", ProjectCopy),
|
||||
...namespaceTypes("PromptInput", "@opencode-ai/schema/prompt-input", PromptInput),
|
||||
...namespaceTypes("Provider", "@opencode-ai/schema/provider", Provider),
|
||||
...namespaceTypes("Pty", "@opencode-ai/schema/pty", Pty),
|
||||
...namespaceTypes("PtyTicket", "@opencode-ai/schema/pty-ticket", PtyTicket),
|
||||
...namespaceTypes("Question", "@opencode-ai/schema/question", Question),
|
||||
...namespaceTypes("Reference", "@opencode-ai/schema/reference", Reference),
|
||||
...namespaceTypes("Session", "@opencode-ai/schema/session", Session),
|
||||
...namespaceTypes("SessionMessage", "@opencode-ai/schema/session-message", SessionMessage),
|
||||
...namespaceTypes("SessionPending", "@opencode-ai/schema/session-pending", SessionPending),
|
||||
...namespaceTypes("Shell", "@opencode-ai/schema/shell", Shell),
|
||||
...namespaceTypes("Skill", "@opencode-ai/schema/skill", Skill),
|
||||
...namespaceTypes("Vcs", "@opencode-ai/schema/vcs", Vcs),
|
||||
...namespaceTypes("WebSearch", "@opencode-ai/schema/websearch", WebSearch),
|
||||
...namespaceTypes("Workspace", "@opencode-ai/schema/workspace", Workspace),
|
||||
typeReference("Prompt", "@opencode-ai/schema/prompt", Prompt),
|
||||
typeReference("PromptMention", "@opencode-ai/schema/prompt", PromptMention),
|
||||
typeReference("FileAttachment", "@opencode-ai/schema/prompt", FileAttachment),
|
||||
typeReference("AgentAttachment", "@opencode-ai/schema/prompt", AgentAttachment),
|
||||
typeReference("AbsolutePath", "@opencode-ai/schema/schema", AbsolutePath),
|
||||
typeReference("PositiveInt", "@opencode-ai/schema/schema", PositiveInt),
|
||||
typeReference("RelativePath", "@opencode-ai/schema/schema", RelativePath),
|
||||
]
|
||||
|
||||
await Effect.runPromise(
|
||||
Effect.all(
|
||||
@@ -22,14 +97,40 @@ await Effect.runPromise(
|
||||
fileURLToPath(new URL("../src/promise/generated", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectImported(effectContract, { module: "../../contract", api: "ClientApi" }),
|
||||
emitEffectImported(effectContract, {
|
||||
module: "../../contract",
|
||||
api: "ClientApi",
|
||||
shapeModule: "../api/api.js",
|
||||
}),
|
||||
fileURLToPath(new URL("../src/effect/generated", import.meta.url)),
|
||||
),
|
||||
write(
|
||||
emitEffectShape(effectContract, { module: "../../contract", api: "ClientApi" }),
|
||||
emitEffectShape(effectContract, {
|
||||
typeReferences: effectTypeReferences,
|
||||
outputTypes: {
|
||||
"event.subscribe": {
|
||||
name: "OpenCodeEvent",
|
||||
import: 'import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"',
|
||||
},
|
||||
},
|
||||
}),
|
||||
fileURLToPath(new URL("../src/effect/api", import.meta.url)),
|
||||
),
|
||||
],
|
||||
{ concurrency: 3, discard: true },
|
||||
).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
function namespaceTypes(namespace: string, module: string, values: object) {
|
||||
return Object.entries(values).flatMap(([name, schema]) =>
|
||||
Schema.isSchema(schema) ? [typeReference(`${namespace}.${name}`, module, schema)] : [],
|
||||
)
|
||||
}
|
||||
|
||||
function typeReference(name: string, module: string, schema: Schema.Top) {
|
||||
return {
|
||||
schema,
|
||||
name,
|
||||
import: `import type { ${name.split(".")[0]} } from ${JSON.stringify(module)}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { ModelApi, ProviderApi } from "./api/api.js"
|
||||
import type { ModelApi, ProviderApi, WebsearchApi } from "./api/api.js"
|
||||
|
||||
export type * from "./api/api.js"
|
||||
|
||||
export type WebSearchApi<E = never> = WebsearchApi<E>
|
||||
|
||||
export interface CatalogApi<E = never> {
|
||||
readonly provider: ProviderApi<E>
|
||||
readonly model: ModelApi<E>
|
||||
|
||||
+1008
-502
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
@@ -35,6 +36,7 @@ export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { WebSearch } from "@opencode-ai/schema/websearch"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
export { SessionPending } from "@opencode-ai/schema/session-pending"
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ModelApi = Client["model"]
|
||||
export type PluginApi = Client["plugin"]
|
||||
export type ProviderApi = Client["provider"]
|
||||
export type ReferenceApi = Client["reference"]
|
||||
export type WebSearchApi = Client["websearch"]
|
||||
export type SessionApi = Client["session"]
|
||||
export type SkillApi = Client["skill"]
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
LocationGetOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
AgentGetInput,
|
||||
AgentGetOutput,
|
||||
PluginListInput,
|
||||
PluginListOutput,
|
||||
SessionListInput,
|
||||
@@ -207,6 +209,10 @@ import type {
|
||||
DebugLocationListOutput,
|
||||
DebugLocationEvictInput,
|
||||
DebugLocationEvictOutput,
|
||||
WebsearchProvidersInput,
|
||||
WebsearchProvidersOutput,
|
||||
WebsearchQueryInput,
|
||||
WebsearchQueryOutput,
|
||||
} from "./types"
|
||||
import { ClientError } from "./client-error"
|
||||
|
||||
@@ -399,6 +405,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: AgentGetInput, requestOptions?: RequestOptions) =>
|
||||
request<AgentGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/agent/${encodeURIComponent(input.agentID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
plugin: {
|
||||
list: (input?: PluginListInput, requestOptions?: RequestOptions) =>
|
||||
@@ -1735,6 +1753,33 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
},
|
||||
websearch: {
|
||||
providers: (input?: WebsearchProvidersInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchProvidersOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/websearch/provider`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
query: (input: WebsearchQueryInput, requestOptions?: RequestOptions) =>
|
||||
request<WebsearchQueryOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/websearch`,
|
||||
query: { location: input["location"] },
|
||||
body: { query: input["query"], providerID: input["providerID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ export type ServiceStopResponse = { accepted: boolean }
|
||||
|
||||
export type ModelRef = { id: string; providerID: string; variant?: string }
|
||||
|
||||
export type ProviderSettings = { [x: string]: JsonValue }
|
||||
export type ProviderSettings = { [x: string]: any }
|
||||
|
||||
export type AgentColor = string
|
||||
|
||||
export type PermissionV2Effect = "allow" | "deny" | "ask"
|
||||
export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginInfo = { id: string }
|
||||
|
||||
@@ -98,8 +98,6 @@ export type SessionMessageShell = {
|
||||
output?: { output: string; cursor: number; size: number; truncated: boolean }
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText = { type: "text"; text: string }
|
||||
|
||||
export type SessionMessageProviderState = { [x: string]: JsonValue }
|
||||
|
||||
export type SessionMessageToolStateStreaming = { status: "streaming"; input: string }
|
||||
@@ -157,8 +155,6 @@ export type ShellInfo = {
|
||||
time: { started: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageProviderState3 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState4 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState5 = { [x: string]: any }
|
||||
@@ -167,6 +163,10 @@ export type SessionMessageProviderState6 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState7 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState8 = { [x: string]: any }
|
||||
|
||||
export type SessionMessageProviderState9 = { [x: string]: any }
|
||||
|
||||
export type EventLogSynced = { type: "log.synced"; aggregateID: string; seq?: number }
|
||||
|
||||
export type ModelReasoningField = "reasoning" | "reasoning_content" | "reasoning_text" | (string & {})
|
||||
@@ -175,24 +175,24 @@ export type ModelCapabilities = { tools: boolean; input: Array<string>; output:
|
||||
|
||||
export type ModelVariant = {
|
||||
id: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type MoneyUSDPerMillionTokens = number
|
||||
|
||||
export type GenerateTextResponse = { data: { text: string } }
|
||||
|
||||
export type ProviderV2Info = {
|
||||
export type ProviderInfo = {
|
||||
id: string
|
||||
integrationID?: string
|
||||
name: string
|
||||
disabled?: boolean
|
||||
package: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
body?: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type IntegrationWhen = { key: string; op: "eq" | "neq"; value: string }
|
||||
@@ -299,7 +299,7 @@ export type FormExternalField = { key: string; type: "external"; url: string; ti
|
||||
|
||||
export type FormValue = string | number | boolean | Array<string>
|
||||
|
||||
export type PermissionV2Source = { type: "tool"; messageID: string; callID: string }
|
||||
export type PermissionSource = { type: "tool"; messageID: string; callID: string }
|
||||
|
||||
export type PermissionSavedInfo = { id: string; projectID: string; action: string; resource: string }
|
||||
|
||||
@@ -323,9 +323,9 @@ export type FileDiffLegacyInfo = {
|
||||
status?: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type PermissionAction = "allow" | "deny" | "ask"
|
||||
export type PermissionV1Action = "allow" | "deny" | "ask"
|
||||
|
||||
export type JSONSchema = { [x: string]: any }
|
||||
export type SessionV1JSONSchema = { [x: string]: any }
|
||||
|
||||
export type ProviderAuthError = { name: "ProviderAuthError"; data: { providerID: string; message: string } }
|
||||
|
||||
@@ -356,7 +356,7 @@ export type APIError = {
|
||||
}
|
||||
}
|
||||
|
||||
export type TextPart = {
|
||||
export type SessionV1TextPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -368,7 +368,7 @@ export type TextPart = {
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
}
|
||||
|
||||
export type SubtaskPart = {
|
||||
export type SessionV1SubtaskPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -380,7 +380,7 @@ export type SubtaskPart = {
|
||||
command?: string | undefined
|
||||
}
|
||||
|
||||
export type ReasoningPart = {
|
||||
export type SessionV1ReasoningPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -390,13 +390,13 @@ export type ReasoningPart = {
|
||||
time: { start: number; end?: number | undefined }
|
||||
}
|
||||
|
||||
export type FilePartSourceText = { value: string; start: number; end: number }
|
||||
export type SessionV1FilePartSourceText = { value: string; start: number; end: number }
|
||||
|
||||
export type Range = { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
export type SessionV1Range = { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
|
||||
export type ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
|
||||
export type SessionV1ToolStatePending = { status: "pending"; input: { [x: string]: any }; raw: string }
|
||||
|
||||
export type ToolStateRunning = {
|
||||
export type SessionV1ToolStateRunning = {
|
||||
status: "running"
|
||||
input: { [x: string]: any }
|
||||
title?: string | undefined
|
||||
@@ -404,7 +404,7 @@ export type ToolStateRunning = {
|
||||
time: { start: number }
|
||||
}
|
||||
|
||||
export type ToolStateError = {
|
||||
export type SessionV1ToolStateError = {
|
||||
status: "error"
|
||||
input: { [x: string]: any }
|
||||
error: string
|
||||
@@ -412,7 +412,7 @@ export type ToolStateError = {
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
|
||||
export type StepStartPart = {
|
||||
export type SessionV1StepStartPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -420,7 +420,7 @@ export type StepStartPart = {
|
||||
snapshot?: string | undefined
|
||||
}
|
||||
|
||||
export type StepFinishPart = {
|
||||
export type SessionV1StepFinishPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -437,9 +437,15 @@ export type StepFinishPart = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SnapshotPart = { id: string; sessionID: string; messageID: string; type: "snapshot"; snapshot: string }
|
||||
export type SessionV1SnapshotPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "snapshot"
|
||||
snapshot: string
|
||||
}
|
||||
|
||||
export type PatchPart = {
|
||||
export type SessionV1PatchPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -448,7 +454,7 @@ export type PatchPart = {
|
||||
files: Array<string>
|
||||
}
|
||||
|
||||
export type AgentPart = {
|
||||
export type SessionV1AgentPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -457,7 +463,7 @@ export type AgentPart = {
|
||||
source?: { value: string; start: number; end: number } | undefined
|
||||
}
|
||||
|
||||
export type CompactionPart = {
|
||||
export type SessionV1CompactionPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -467,7 +473,7 @@ export type CompactionPart = {
|
||||
tail_start_id?: string | undefined
|
||||
}
|
||||
|
||||
export type PermissionV2Reply = "once" | "always" | "reject"
|
||||
export type PermissionReply = "once" | "always" | "reject"
|
||||
|
||||
export type Pty = {
|
||||
id: string
|
||||
@@ -480,11 +486,11 @@ export type Pty = {
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
export type QuestionV2Option = { label: string; description: string }
|
||||
export type QuestionOption = { label: string; description: string }
|
||||
|
||||
export type QuestionV2Tool = { messageID: string; callID: string }
|
||||
export type QuestionTool = { messageID: string; callID: string }
|
||||
|
||||
export type QuestionV2Answer = Array<string>
|
||||
export type QuestionAnswer = Array<string>
|
||||
|
||||
export type FormMetadata1 = { [x: string]: any }
|
||||
|
||||
@@ -501,12 +507,6 @@ export type SessionStatus =
|
||||
}
|
||||
| { type: "busy" }
|
||||
|
||||
export type QuestionOption = { label: string; description: string }
|
||||
|
||||
export type QuestionTool = { messageID: string; callID: string }
|
||||
|
||||
export type QuestionAnswer = Array<string>
|
||||
|
||||
export type ShellInfo1 = {
|
||||
id: string
|
||||
status: "running" | "exited" | "timeout" | "killed"
|
||||
@@ -539,6 +539,10 @@ export type VcsFileStatus = {
|
||||
status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
export type WebSearchProvider = { id: string; name: string }
|
||||
|
||||
export type WebSearchResult = { url: string; title?: string; content?: string; time: { published?: number } }
|
||||
|
||||
export type SessionMessageModelSelected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
@@ -560,10 +564,10 @@ export type CommandInfo = {
|
||||
export type ProviderRequest = {
|
||||
settings: ProviderSettings
|
||||
headers: { [x: string]: string }
|
||||
body: { [x: string]: JsonValue }
|
||||
body: { [x: string]: any }
|
||||
}
|
||||
|
||||
export type PermissionV2Rule = { action: string; resource: string; effect: PermissionV2Effect }
|
||||
export type PermissionRule = { action: string; resource: string; effect: PermissionEffect }
|
||||
|
||||
export type SessionAgentSelected = {
|
||||
id: string
|
||||
@@ -733,16 +737,6 @@ export type SessionTextStarted = {
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number }
|
||||
}
|
||||
|
||||
export type SessionTextEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; text: string }
|
||||
}
|
||||
|
||||
export type SessionToolInputStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1050,11 +1044,11 @@ export type ShellDeleted = {
|
||||
data: { id: string }
|
||||
}
|
||||
|
||||
export type QuestionV2Rejected = {
|
||||
export type QuestionRejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.v2.rejected"
|
||||
type: "question.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
}
|
||||
@@ -1068,6 +1062,15 @@ export type FormCancelled = {
|
||||
data: { id: string; sessionID: string }
|
||||
}
|
||||
|
||||
export type WebsearchUpdated = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "websearch.updated"
|
||||
location?: LocationRef
|
||||
data: {}
|
||||
}
|
||||
|
||||
export type SessionIdle = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1183,41 +1186,6 @@ export type McpResourcesChanged = {
|
||||
data: { server: string }
|
||||
}
|
||||
|
||||
export type PermissionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "permission.asked"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
id: string
|
||||
sessionID: string
|
||||
permission: string
|
||||
patterns: Array<string>
|
||||
metadata: { [x: string]: any }
|
||||
always: Array<string>
|
||||
tool?: { messageID: string; callID: string } | undefined
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "permission.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; reply: "once" | "always" | "reject" }
|
||||
}
|
||||
|
||||
export type QuestionRejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
}
|
||||
|
||||
export type V2EventServerConnected = {
|
||||
id: string
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
@@ -1249,6 +1217,8 @@ export type SessionPendingSynthetic = {
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
|
||||
|
||||
export type SessionMessageAssistantReasoning = {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
@@ -1359,6 +1329,22 @@ export type ShellCreated = {
|
||||
data: { info: ShellInfo }
|
||||
}
|
||||
|
||||
export type SessionTextEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.text.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
assistantMessageID: string
|
||||
ordinal: number
|
||||
text: string
|
||||
state?: SessionMessageProviderState4
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionReasoningStarted = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1366,7 +1352,7 @@ export type SessionReasoningStarted = {
|
||||
type: "session.reasoning.started"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState3 }
|
||||
data: { sessionID: string; assistantMessageID: string; ordinal: number; state?: SessionMessageProviderState5 }
|
||||
}
|
||||
|
||||
export type SessionReasoningEnded = {
|
||||
@@ -1381,7 +1367,7 @@ export type SessionReasoningEnded = {
|
||||
assistantMessageID: string
|
||||
ordinal: number
|
||||
text: string
|
||||
state?: SessionMessageProviderState4
|
||||
state?: SessionMessageProviderState6
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1398,7 +1384,7 @@ export type SessionToolCalled = {
|
||||
callID: string
|
||||
input: { [x: string]: any }
|
||||
executed: boolean
|
||||
state?: SessionMessageProviderState5
|
||||
state?: SessionMessageProviderState7
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1523,21 +1509,21 @@ export type FormMultiselectField = {
|
||||
|
||||
export type FormAnswer = { [x: string]: FormValue }
|
||||
|
||||
export type PermissionV2Request = {
|
||||
export type PermissionRequest = {
|
||||
id: string
|
||||
sessionID: string
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
source?: PermissionV2Source
|
||||
source?: PermissionSource
|
||||
}
|
||||
|
||||
export type PermissionV2Asked = {
|
||||
export type PermissionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "permission.v2.asked"
|
||||
type: "permission.asked"
|
||||
location?: LocationRef
|
||||
data: {
|
||||
id: string
|
||||
@@ -1546,17 +1532,17 @@ export type PermissionV2Asked = {
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
metadata?: { [x: string]: any }
|
||||
source?: PermissionV2Source
|
||||
source?: PermissionSource
|
||||
}
|
||||
}
|
||||
|
||||
export type PermissionRule = { permission: string; pattern: string; action: PermissionAction }
|
||||
export type PermissionV1Rule = { permission: string; pattern: string; action: PermissionV1Action }
|
||||
|
||||
export type OutputFormat =
|
||||
export type SessionV1OutputFormat =
|
||||
| { type: "text" }
|
||||
| { type: "json_schema"; schema: JSONSchema; retryCount?: number | undefined | undefined }
|
||||
| { type: "json_schema"; schema: SessionV1JSONSchema; retryCount?: number | undefined | undefined }
|
||||
|
||||
export type AssistantMessage = {
|
||||
export type SessionV1AssistantMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "assistant"
|
||||
@@ -1591,7 +1577,7 @@ export type AssistantMessage = {
|
||||
finish?: string | undefined
|
||||
}
|
||||
|
||||
export type RetryPart = {
|
||||
export type SessionV1RetryPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -1622,26 +1608,31 @@ export type SessionError = {
|
||||
}
|
||||
}
|
||||
|
||||
export type FileSource = { text: FilePartSourceText; type: "file"; path: string }
|
||||
export type SessionV1FileSource = { text: SessionV1FilePartSourceText; type: "file"; path: string }
|
||||
|
||||
export type ResourceSource = { text: FilePartSourceText; type: "resource"; clientName: string; uri: string }
|
||||
export type SessionV1ResourceSource = {
|
||||
text: SessionV1FilePartSourceText
|
||||
type: "resource"
|
||||
clientName: string
|
||||
uri: string
|
||||
}
|
||||
|
||||
export type SymbolSource = {
|
||||
text: FilePartSourceText
|
||||
export type SessionV1SymbolSource = {
|
||||
text: SessionV1FilePartSourceText
|
||||
type: "symbol"
|
||||
path: string
|
||||
range: Range
|
||||
range: SessionV1Range
|
||||
name: string
|
||||
kind: number
|
||||
}
|
||||
|
||||
export type PermissionV2Replied = {
|
||||
export type PermissionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "permission.v2.replied"
|
||||
type: "permission.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; reply: PermissionV2Reply }
|
||||
data: { sessionID: string; requestID: string; reply: PermissionReply }
|
||||
}
|
||||
|
||||
export type PtyCreated = {
|
||||
@@ -1662,21 +1653,21 @@ export type PtyUpdated = {
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type QuestionV2Info = {
|
||||
export type QuestionInfo = {
|
||||
question: string
|
||||
header: string
|
||||
options: Array<QuestionV2Option>
|
||||
options: Array<QuestionOption>
|
||||
multiple?: boolean
|
||||
custom?: boolean
|
||||
}
|
||||
|
||||
export type QuestionV2Replied = {
|
||||
export type QuestionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.v2.replied"
|
||||
type: "question.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionV2Answer> }
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
|
||||
}
|
||||
|
||||
export type FormStringField1 = {
|
||||
@@ -1753,26 +1744,9 @@ export type SessionStatus2 = {
|
||||
data: { sessionID: string; status: SessionStatus }
|
||||
}
|
||||
|
||||
export type QuestionInfo = {
|
||||
question: string
|
||||
header: string
|
||||
options: Array<QuestionOption>
|
||||
multiple?: boolean | undefined
|
||||
custom?: boolean | undefined
|
||||
}
|
||||
|
||||
export type QuestionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
|
||||
}
|
||||
|
||||
export type ReferenceSource = ReferenceLocalSource | ReferenceGitSource
|
||||
|
||||
export type PermissionV2Ruleset = Array<PermissionV2Rule>
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
@@ -1853,7 +1827,7 @@ export type SessionToolSuccess = {
|
||||
content: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState6
|
||||
resultState?: SessionMessageProviderState8
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1872,7 +1846,7 @@ export type SessionToolFailed = {
|
||||
content?: [LLMToolContent, ...Array<LLMToolContent>]
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
executed: boolean
|
||||
resultState?: SessionMessageProviderState7
|
||||
resultState?: SessionMessageProviderState9
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1889,9 +1863,9 @@ export type ModelInfo = {
|
||||
name: string
|
||||
compatibility?: ModelCompatibility
|
||||
package?: string
|
||||
settings?: { [x: string]: JsonValue }
|
||||
settings?: { [x: string]: any }
|
||||
headers?: { [x: string]: string }
|
||||
body?: { [x: string]: JsonValue }
|
||||
body?: { [x: string]: any }
|
||||
capabilities: ModelCapabilities
|
||||
variants: Array<ModelVariant>
|
||||
time: { released: number }
|
||||
@@ -1927,14 +1901,14 @@ export type FormReplied = {
|
||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||
}
|
||||
|
||||
export type PermissionRuleset = Array<PermissionRule>
|
||||
export type PermissionV1Ruleset = Array<PermissionV1Rule>
|
||||
|
||||
export type UserMessage = {
|
||||
export type SessionV1UserMessage = {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user"
|
||||
time: { created: number }
|
||||
format?: OutputFormat | undefined
|
||||
format?: SessionV1OutputFormat | undefined
|
||||
summary?: { title?: string | undefined; body?: string | undefined; diffs: Array<FileDiffLegacyInfo> } | undefined
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string; variant?: string | undefined }
|
||||
@@ -1942,23 +1916,18 @@ export type UserMessage = {
|
||||
tools?: { [x: string]: boolean } | undefined
|
||||
}
|
||||
|
||||
export type FilePartSource = FileSource | SymbolSource | ResourceSource
|
||||
export type SessionV1FilePartSource = SessionV1FileSource | SessionV1SymbolSource | SessionV1ResourceSource
|
||||
|
||||
export type QuestionV2Asked = {
|
||||
export type QuestionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.v2.asked"
|
||||
type: "question.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionV2Info>; tool?: QuestionV2Tool }
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
||||
}
|
||||
|
||||
export type QuestionV2Request = {
|
||||
id: string
|
||||
sessionID: string
|
||||
questions: Array<QuestionV2Info>
|
||||
tool?: QuestionV2Tool
|
||||
}
|
||||
export type QuestionRequest = { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
||||
|
||||
export type FormField1 =
|
||||
| FormStringField1
|
||||
@@ -1968,15 +1937,6 @@ export type FormField1 =
|
||||
| FormMultiselectField1
|
||||
| FormExternalField
|
||||
|
||||
export type QuestionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool | undefined }
|
||||
}
|
||||
|
||||
export type ReferenceInfo = {
|
||||
name: string
|
||||
path: string
|
||||
@@ -1996,7 +1956,7 @@ export type AgentInfo = {
|
||||
hidden: boolean
|
||||
color?: AgentColor
|
||||
steps?: number
|
||||
permissions: PermissionV2Ruleset
|
||||
permissions: PermissionRuleset
|
||||
}
|
||||
|
||||
export type SessionsResponse = { data: Array<SessionInfo>; cursor: { previous?: string | null; next?: string | null } }
|
||||
@@ -2054,13 +2014,13 @@ export type SessionV1Info = {
|
||||
version: string
|
||||
metadata?: { [x: string]: any }
|
||||
time: { created: number; updated: number; compacting?: number; archived?: number }
|
||||
permission?: PermissionRuleset
|
||||
permission?: PermissionV1Ruleset
|
||||
revert?: { messageID: string; partID?: string; snapshot?: string; diff?: string }
|
||||
}
|
||||
|
||||
export type Message = UserMessage | AssistantMessage
|
||||
export type SessionV1Message = SessionV1UserMessage | SessionV1AssistantMessage
|
||||
|
||||
export type FilePart = {
|
||||
export type SessionV1FilePart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
@@ -2068,7 +2028,7 @@ export type FilePart = {
|
||||
mime: string
|
||||
filename?: string | undefined
|
||||
url: string
|
||||
source?: FilePartSource | undefined
|
||||
source?: SessionV1FilePartSource | undefined
|
||||
}
|
||||
|
||||
export type FormFields1 = [FormField1, ...Array<FormField1>]
|
||||
@@ -2139,17 +2099,17 @@ export type MessageUpdated = {
|
||||
type: "message.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; info: Message }
|
||||
data: { sessionID: string; info: SessionV1Message }
|
||||
}
|
||||
|
||||
export type ToolStateCompleted = {
|
||||
export type SessionV1ToolStateCompleted = {
|
||||
status: "completed"
|
||||
input: { [x: string]: any }
|
||||
output: string
|
||||
title: string
|
||||
metadata: { [x: string]: any }
|
||||
time: { start: number; end: number; compacted?: number | undefined }
|
||||
attachments?: Array<FilePart> | undefined
|
||||
attachments?: Array<SessionV1FilePart> | undefined
|
||||
}
|
||||
|
||||
export type FormInfo1 = { id: string; sessionID: string; title: string; metadata?: FormMetadata1; fields: FormFields1 }
|
||||
@@ -2175,7 +2135,11 @@ export type SessionMessageInfo =
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
|
||||
export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError
|
||||
export type SessionV1ToolState =
|
||||
| SessionV1ToolStatePending
|
||||
| SessionV1ToolStateRunning
|
||||
| SessionV1ToolStateCompleted
|
||||
| SessionV1ToolStateError
|
||||
|
||||
export type FormCreated = {
|
||||
id: string
|
||||
@@ -2231,32 +2195,32 @@ export type SessionMessagesResponse = {
|
||||
cursor: { previous?: string | null; next?: string | null }
|
||||
}
|
||||
|
||||
export type ToolPart = {
|
||||
export type SessionV1ToolPart = {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: ToolState
|
||||
state: SessionV1ToolState
|
||||
metadata?: { [x: string]: any } | undefined
|
||||
}
|
||||
|
||||
export type SessionLogItem = SessionEventDurable | EventLogSynced
|
||||
|
||||
export type Part =
|
||||
| TextPart
|
||||
| SubtaskPart
|
||||
| ReasoningPart
|
||||
| FilePart
|
||||
| ToolPart
|
||||
| StepStartPart
|
||||
| StepFinishPart
|
||||
| SnapshotPart
|
||||
| PatchPart
|
||||
| AgentPart
|
||||
| RetryPart
|
||||
| CompactionPart
|
||||
export type SessionV1Part =
|
||||
| SessionV1TextPart
|
||||
| SessionV1SubtaskPart
|
||||
| SessionV1ReasoningPart
|
||||
| SessionV1FilePart
|
||||
| SessionV1ToolPart
|
||||
| SessionV1StepStartPart
|
||||
| SessionV1StepFinishPart
|
||||
| SessionV1SnapshotPart
|
||||
| SessionV1PatchPart
|
||||
| SessionV1AgentPart
|
||||
| SessionV1RetryPart
|
||||
| SessionV1CompactionPart
|
||||
|
||||
export type MessagePartUpdated = {
|
||||
id: string
|
||||
@@ -2265,7 +2229,7 @@ export type MessagePartUpdated = {
|
||||
type: "message.part.updated"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; part: Part; time: number }
|
||||
data: { sessionID: string; part: SessionV1Part; time: number }
|
||||
}
|
||||
|
||||
export type V2Event =
|
||||
@@ -2326,8 +2290,8 @@ export type V2Event =
|
||||
| SessionRevertCommitted
|
||||
| FilesystemChanged
|
||||
| ReferenceUpdated
|
||||
| PermissionV2Asked
|
||||
| PermissionV2Replied
|
||||
| PermissionAsked
|
||||
| PermissionReplied
|
||||
| PluginAdded
|
||||
| PluginUpdated
|
||||
| ProjectDirectoriesUpdated
|
||||
@@ -2341,12 +2305,13 @@ export type V2Event =
|
||||
| ShellCreated
|
||||
| ShellExited
|
||||
| ShellDeleted
|
||||
| QuestionV2Asked
|
||||
| QuestionV2Replied
|
||||
| QuestionV2Rejected
|
||||
| QuestionAsked
|
||||
| QuestionReplied
|
||||
| QuestionRejected
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
| WebsearchUpdated
|
||||
| SessionStatus2
|
||||
| SessionIdle
|
||||
| TuiPromptAppend
|
||||
@@ -2358,11 +2323,6 @@ export type V2Event =
|
||||
| VcsBranchUpdated
|
||||
| McpStatusChanged
|
||||
| McpResourcesChanged
|
||||
| PermissionAsked
|
||||
| PermissionReplied
|
||||
| QuestionAsked
|
||||
| QuestionReplied
|
||||
| QuestionRejected
|
||||
| SessionError
|
||||
| V2EventServerConnected
|
||||
|
||||
@@ -2379,6 +2339,14 @@ export type InvalidRequestError = {
|
||||
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
|
||||
|
||||
export type AgentNotFoundError = {
|
||||
readonly _tag: "AgentNotFoundError"
|
||||
readonly agentID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isAgentNotFoundError = (value: unknown): value is AgentNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "AgentNotFoundError"
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
@@ -2562,6 +2530,18 @@ export type AgentListOutput = {
|
||||
data: Array<AgentInfo>
|
||||
}
|
||||
|
||||
export type AgentGetInput = {
|
||||
readonly agentID: { readonly agentID: string }["agentID"]
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type AgentGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: AgentInfo
|
||||
}
|
||||
|
||||
export type PluginListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -3290,7 +3270,7 @@ export type ProviderListInput = {
|
||||
|
||||
export type ProviderListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<ProviderV2Info>
|
||||
data: Array<ProviderInfo>
|
||||
}
|
||||
|
||||
export type ProviderGetInput = {
|
||||
@@ -3302,7 +3282,7 @@ export type ProviderGetInput = {
|
||||
|
||||
export type ProviderGetOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: ProviderV2Info
|
||||
data: ProviderInfo
|
||||
}
|
||||
|
||||
export type IntegrationListInput = {
|
||||
@@ -4454,7 +4434,7 @@ export type PermissionRequestListInput = {
|
||||
|
||||
export type PermissionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<PermissionV2Request>
|
||||
data: Array<PermissionRequest>
|
||||
}
|
||||
|
||||
export type PermissionSavedListInput = { readonly projectID?: { readonly projectID?: string | undefined }["projectID"] }
|
||||
@@ -4532,18 +4512,18 @@ export type PermissionCreateInput = {
|
||||
}["agent"]
|
||||
}
|
||||
|
||||
export type PermissionCreateOutput = { data: { id: string; effect: PermissionV2Effect } }["data"]
|
||||
export type PermissionCreateOutput = { data: { id: string; effect: PermissionEffect } }["data"]
|
||||
|
||||
export type PermissionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type PermissionListOutput = { data: Array<PermissionV2Request> }["data"]
|
||||
export type PermissionListOutput = { data: Array<PermissionRequest> }["data"]
|
||||
|
||||
export type PermissionGetInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
}
|
||||
|
||||
export type PermissionGetOutput = { data: PermissionV2Request }["data"]
|
||||
export type PermissionGetOutput = { data: PermissionRequest }["data"]
|
||||
|
||||
export type PermissionReplyInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
@@ -4842,12 +4822,12 @@ export type QuestionRequestListInput = {
|
||||
|
||||
export type QuestionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<QuestionV2Request>
|
||||
data: Array<QuestionRequest>
|
||||
}
|
||||
|
||||
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type QuestionListOutput = { data: Array<QuestionV2Request> }["data"]
|
||||
export type QuestionListOutput = { data: Array<QuestionRequest> }["data"]
|
||||
|
||||
export type QuestionReplyInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
@@ -4950,3 +4930,27 @@ export type DebugLocationEvictInput = {
|
||||
}
|
||||
|
||||
export type DebugLocationEvictOutput = void
|
||||
|
||||
export type WebsearchProvidersInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type WebsearchProvidersOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: Array<WebSearchProvider>
|
||||
}
|
||||
|
||||
export type WebsearchQueryInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly query: { readonly query: string; readonly providerID?: string }["query"]
|
||||
readonly providerID?: { readonly query: string; readonly providerID?: string }["providerID"]
|
||||
}
|
||||
|
||||
export type WebsearchQueryOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
data: { providerID: string; results: Array<WebSearchResult> }
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
PluginApi,
|
||||
ProviderApi,
|
||||
ReferenceApi,
|
||||
WebSearchApi,
|
||||
SessionApi,
|
||||
SkillApi,
|
||||
} from "./api.js"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode as EffectOpenCode, type AppApi as EffectApi } from "../src/effect"
|
||||
import type { Session } from "@opencode-ai/schema/session"
|
||||
|
||||
type EffectClient = Effect.Success<ReturnType<typeof EffectOpenCode.make>>
|
||||
type PromiseClient = ReturnType<typeof import("../src/promise").OpenCode.make>
|
||||
@@ -9,6 +10,10 @@ declare const promiseClient: PromiseClient
|
||||
|
||||
const effectApi: EffectApi<unknown> = effectClient
|
||||
|
||||
const effectSession: Effect.Effect<Session.Info, unknown> = effectClient.session.get({
|
||||
sessionID: "ses_test" as Session.ID,
|
||||
})
|
||||
|
||||
declare const sessionID: Parameters<typeof effectApi.session.instructions.entry.list>[0]["sessionID"]
|
||||
|
||||
const effectList: Effect.Effect<
|
||||
@@ -37,4 +42,4 @@ const promiseRemove: Promise<void> = promiseClient.session.instructions.entry.re
|
||||
key: "review-notes",
|
||||
})
|
||||
|
||||
void [effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
|
||||
void [effectSession, effectList, effectPut, effectRemove, promiseList, promisePut, promiseRemove]
|
||||
|
||||
@@ -14,6 +14,14 @@ test("effect entrypoint exposes canonical Schema contracts", () => {
|
||||
expect(Client.Session).toBe(Session)
|
||||
})
|
||||
|
||||
test("generated Effect API names canonical and composed outputs", async () => {
|
||||
const source = await Bun.file(new URL("../src/effect/api/api.ts", import.meta.url)).text()
|
||||
|
||||
expect(source).toContain("export type Endpoint5_3Output = Session.Info")
|
||||
expect(source).toContain("export type Endpoint19_0Output = OpenCodeEvent")
|
||||
expect(source).not.toContain("HttpApiClient.ForApi")
|
||||
})
|
||||
|
||||
test("shared DTO schemas construct and decode plain objects", () => {
|
||||
const made = Prompt.make({ text: "hello" })
|
||||
const decoded = Schema.decodeUnknownSync(Prompt)({ text: "hello" })
|
||||
|
||||
@@ -32,6 +32,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"projectCopy",
|
||||
"vcs",
|
||||
"debug",
|
||||
"websearch",
|
||||
])
|
||||
expect(Object.keys(client.debug)).toEqual(["location"])
|
||||
expect(Object.keys(client.debug.location)).toEqual(["list", "evict"])
|
||||
@@ -41,6 +42,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.integration.connect)).toEqual(["key"])
|
||||
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
|
||||
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
|
||||
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
|
||||
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
|
||||
expect(Object.keys(client.vcs)).toEqual(["status", "diff"])
|
||||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
@@ -48,6 +50,37 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("websearch.query uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: {
|
||||
providerID: "exa",
|
||||
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.websearch.query({
|
||||
query: "opencode",
|
||||
providerID: "exa",
|
||||
location: { directory: "/tmp/project" },
|
||||
})
|
||||
|
||||
expect(result.data).toEqual({
|
||||
providerID: "exa",
|
||||
results: [{ url: "https://example.com", title: "Result", content: "result", time: {} }],
|
||||
})
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe("http://localhost:3000/api/websearch?location%5Bdirectory%5D=%2Ftmp%2Fproject")
|
||||
expect(await request?.json()).toEqual({ query: "opencode", providerID: "exa" })
|
||||
})
|
||||
|
||||
test("server.get uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -40,7 +40,7 @@ export type ExecuteOptions<Provided extends Record<string, unknown> = {}> = {
|
||||
limits?: ExecutionLimits
|
||||
/** Observes decoded tool input immediately before tool execution. */
|
||||
onToolCallStart?: (call: ToolRuntime.ToolCallStarted) => Effect.Effect<void, never, Services<Provided>>
|
||||
/** Observes each admitted tool call as it settles, with outcome and duration. */
|
||||
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
|
||||
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Provided>>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { Cause, Effect, Exit, Schema } from "effect"
|
||||
import { ToolError, toolError } from "./tool-error.js"
|
||||
import {
|
||||
decodeInput as decodeToolInput,
|
||||
@@ -52,7 +52,7 @@ export type ToolCallEnded = {
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly durationMs: number
|
||||
readonly outcome: "success" | "failure"
|
||||
readonly outcome: "success" | "failure" | "interrupted"
|
||||
readonly message?: string
|
||||
}
|
||||
|
||||
@@ -342,7 +342,6 @@ export type DiscoveryPlan = {
|
||||
|
||||
export type SearchEntry = {
|
||||
readonly description: ToolDescription
|
||||
readonly namespace: string
|
||||
readonly searchText: string
|
||||
}
|
||||
|
||||
@@ -373,7 +372,11 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
|
||||
const scoped =
|
||||
request.namespace === undefined
|
||||
? searchIndex
|
||||
: searchIndex.filter((entry) => entry.namespace === request.namespace)
|
||||
: searchIndex.filter(
|
||||
(entry) =>
|
||||
entry.description.path === request.namespace ||
|
||||
entry.description.path.startsWith(`${request.namespace}.`),
|
||||
)
|
||||
const trimmed = query.trim()
|
||||
const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
|
||||
const exact =
|
||||
@@ -428,7 +431,6 @@ export const searchSignature = (() => {
|
||||
|
||||
const toSearchEntry = <R>(path: string, tool: Tool<R>, description: ToolDescription): SearchEntry => ({
|
||||
description,
|
||||
namespace: path.split(".", 1)[0]!,
|
||||
searchText: [
|
||||
path,
|
||||
tool.description,
|
||||
@@ -495,22 +497,19 @@ export const make = <R>(
|
||||
const root = toolTrie(tools)
|
||||
const searchTool = makeSearchTool(searchIndex)
|
||||
|
||||
// End hooks observe settled success or failure; interruption emits neither outcome.
|
||||
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
||||
const onEnd = hooks?.onToolCallEnd
|
||||
if (onEnd === undefined) return effect
|
||||
const startedAt = Date.now()
|
||||
return effect.pipe(
|
||||
Effect.tap(() => onEnd({ ...call, durationMs: Date.now() - startedAt, outcome: "success" })),
|
||||
Effect.tapError((error) => {
|
||||
Effect.onExit((exit) => {
|
||||
const durationMs = Date.now() - startedAt
|
||||
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
|
||||
const error = Cause.squash(exit.cause)
|
||||
const message =
|
||||
error instanceof ToolError || error instanceof ToolRuntimeError ? error.message : "Tool execution failed"
|
||||
return onEnd({
|
||||
...call,
|
||||
durationMs: Date.now() - startedAt,
|
||||
outcome: "failure",
|
||||
message,
|
||||
})
|
||||
return onEnd({ ...call, durationMs, outcome: "failure", message })
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -528,12 +527,6 @@ export const make = <R>(
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
const recordAndObserve = (name: string, input: unknown) =>
|
||||
Effect.sync(() => {
|
||||
recordCall({ name })
|
||||
return calls.length - 1
|
||||
}).pipe(Effect.tap((index) => hooks?.onToolCallStart?.({ index, name, input }) ?? Effect.void))
|
||||
|
||||
const executeTool = (name: string, tool: Tool<R>, externalArgs: Array<unknown>) =>
|
||||
Effect.gen(function* () {
|
||||
if (externalArgs.length !== 1)
|
||||
@@ -547,9 +540,14 @@ export const make = <R>(
|
||||
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
|
||||
),
|
||||
})
|
||||
const index = yield* recordAndObserve(name, input)
|
||||
const index = yield* Effect.sync(() => {
|
||||
recordCall({ name })
|
||||
return calls.length - 1
|
||||
})
|
||||
const call = { index, name, input }
|
||||
return yield* observeEnd(
|
||||
Effect.gen(function* () {
|
||||
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
|
||||
const raw = yield* runHost(Effect.suspend(() => tool.execute(input)))
|
||||
const result = yield* Effect.try({
|
||||
try: () => decodeToolOutput(tool, raw),
|
||||
@@ -557,7 +555,7 @@ export const make = <R>(
|
||||
})
|
||||
return yield* decodeOutput(result, name)
|
||||
}),
|
||||
{ index, name, input },
|
||||
call,
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -189,7 +189,12 @@ describe("CodeMode tool-call observation", () => {
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
|
||||
execute: ({ query }) =>
|
||||
query === "boom"
|
||||
? Effect.fail(toolError("Lookup refused"))
|
||||
: query === "defect"
|
||||
? Effect.die("broken")
|
||||
: Effect.succeed(query),
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({
|
||||
@@ -215,14 +220,98 @@ describe("CodeMode tool-call observation", () => {
|
||||
expect(success.ok).toBe(true)
|
||||
const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
|
||||
expect(failure.ok).toBe(false)
|
||||
const defect = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "defect" })`))
|
||||
expect(defect.ok).toBe(false)
|
||||
|
||||
expect(events).toStrictEqual([
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "success" },
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Tool execution failed" },
|
||||
])
|
||||
})
|
||||
|
||||
test("observes interrupted calls", async () => {
|
||||
const events: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Interrupt",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.interrupt,
|
||||
})
|
||||
const exit = await Effect.runPromiseExit(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.sync(() => events.push("start")),
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(events).toEqual(["start", "end:interrupted"])
|
||||
})
|
||||
|
||||
test("observes running calls interrupted during completion", async () => {
|
||||
const events: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Pending",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.never,
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.sync(() => events.push("start")),
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
|
||||
}).execute('tools.host.call({}); return "done"'),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: true, value: "done" })
|
||||
expect(events).toEqual(["start", "end:interrupted"])
|
||||
})
|
||||
|
||||
test("ends calls interrupted during start observation", async () => {
|
||||
const events: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Unused",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("unused"),
|
||||
})
|
||||
const exit = await Effect.runPromiseExit(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.interrupt,
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(events).toEqual(["interrupted"])
|
||||
})
|
||||
|
||||
test("observes calls interrupted by the execution timeout", async () => {
|
||||
const outcomes: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Pending",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.never,
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
limits: { timeoutMs: 10 },
|
||||
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
|
||||
expect(outcomes).toEqual(["interrupted"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeMode console capture", () => {
|
||||
|
||||
@@ -54,6 +54,27 @@ describe("dotted tool names", () => {
|
||||
expect(flat.catalog()[0]?.path).toBe("issues.list")
|
||||
expect(await value(flat, `return await tools.issues.list({})`)).toBe("flat")
|
||||
})
|
||||
|
||||
test("search scopes to a nested namespace subtree", async () => {
|
||||
const nested = CodeMode.make({
|
||||
tools: {
|
||||
slack: {
|
||||
admin: echo("Admin", "admin"),
|
||||
"admin.invite": echo("Invite", "invite"),
|
||||
"admin.users.list": echo("List users", "users"),
|
||||
"administrator.list": echo("List administrators", "administrators"),
|
||||
read: echo("Read Slack", "read"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const result = await value(nested, `return search({ query: "", namespace: "slack.admin" })`)
|
||||
expect((result as { items: Array<{ path: string }> }).items.map((item) => item.path)).toEqual([
|
||||
"tools.slack.admin",
|
||||
"tools.slack.admin.invite",
|
||||
"tools.slack.admin.users.list",
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("callable namespaces", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export async function GET() {
|
||||
const response = await fetch(
|
||||
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/sdk/openapi.json",
|
||||
"https://raw.githubusercontent.com/anomalyco/opencode/refs/heads/dev/packages/protocol/openapi.json",
|
||||
)
|
||||
const json = await response.json()
|
||||
return json
|
||||
|
||||
@@ -99,6 +99,7 @@
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"@openrouter/ai-sdk-provider": "2.9.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * as AccountV2 from "./account"
|
||||
export * as Account from "./account"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import type { HttpClientError } from "effect/unstable/http"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { sqliteTable, text, integer, primaryKey } from "drizzle-orm/sqlite-core"
|
||||
|
||||
import { AccountV2 } from "../account"
|
||||
import { Account } from "../account"
|
||||
import { Timestamps } from "../database/schema.sql"
|
||||
|
||||
export const AccountTable = sqliteTable("account", {
|
||||
id: text().$type<AccountV2.ID>().primaryKey(),
|
||||
id: text().$type<Account.ID>().primaryKey(),
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<AccountV2.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
|
||||
access_token: text().$type<Account.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<Account.RefreshToken>().notNull(),
|
||||
token_expiry: integer(),
|
||||
...Timestamps,
|
||||
})
|
||||
@@ -16,9 +16,9 @@ export const AccountTable = sqliteTable("account", {
|
||||
export const AccountStateTable = sqliteTable("account_state", {
|
||||
id: integer().primaryKey(),
|
||||
active_account_id: text()
|
||||
.$type<AccountV2.ID>()
|
||||
.$type<Account.ID>()
|
||||
.references(() => AccountTable.id, { onDelete: "set null" }),
|
||||
active_org_id: text().$type<AccountV2.OrgID>(),
|
||||
active_org_id: text().$type<Account.OrgID>(),
|
||||
})
|
||||
|
||||
// LEGACY
|
||||
@@ -27,8 +27,8 @@ export const ControlAccountTable = sqliteTable(
|
||||
{
|
||||
email: text().notNull(),
|
||||
url: text().notNull(),
|
||||
access_token: text().$type<AccountV2.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<AccountV2.RefreshToken>().notNull(),
|
||||
access_token: text().$type<Account.AccessToken>().notNull(),
|
||||
refresh_token: text().$type<Account.RefreshToken>().notNull(),
|
||||
token_expiry: integer(),
|
||||
active: integer({ mode: "boolean" })
|
||||
.notNull()
|
||||
|
||||
+12
-12
@@ -1,9 +1,9 @@
|
||||
export * as AgentV2 from "./agent"
|
||||
export * as Agent from "./agent"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Types } from "effect"
|
||||
import { Agent } from "@opencode-ai/schema/agent"
|
||||
import { EventV2 } from "./event"
|
||||
import { Bus } from "./bus"
|
||||
import { State } from "./state"
|
||||
|
||||
export const ID = Agent.ID
|
||||
@@ -17,7 +17,7 @@ export const Color = Agent.Color
|
||||
export const Info = Agent.Info
|
||||
export type Info = Agent.Info
|
||||
|
||||
export const Event = Agent.Event
|
||||
export { Event } from "@opencode-ai/schema/agent"
|
||||
|
||||
export interface Selection {
|
||||
readonly id: ID
|
||||
@@ -45,12 +45,12 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Agent") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const bus = yield* Bus.Service
|
||||
const state = State.create<Data, Draft>({
|
||||
name: "agent",
|
||||
initial: () => ({ agents: new Map() }),
|
||||
@@ -70,7 +70,7 @@ const layer = Layer.effect(
|
||||
draft.agents.delete(id)
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(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
|
||||
@@ -89,17 +89,17 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
get: Effect.fn("AgentV2.get")(function* (id) {
|
||||
get: Effect.fn("Agent.get")(function* (id) {
|
||||
return state.get().agents.get(id)
|
||||
}),
|
||||
default: Effect.fn("AgentV2.default")(function* () {
|
||||
default: Effect.fn("Agent.default")(function* () {
|
||||
return selectedDefault()
|
||||
}),
|
||||
resolve: Effect.fn("AgentV2.resolve")(function* (id) {
|
||||
resolve: Effect.fn("Agent.resolve")(function* (id) {
|
||||
if (id !== undefined) return state.get().agents.get(ID.make(id))
|
||||
return selectedDefault()
|
||||
}),
|
||||
select: Effect.fn("AgentV2.select")(function* (id) {
|
||||
select: Effect.fn("Agent.select")(function* (id) {
|
||||
if (id !== undefined) {
|
||||
const selected = ID.make(id)
|
||||
return { id: selected, info: state.get().agents.get(selected) }
|
||||
@@ -107,11 +107,11 @@ const layer = Layer.effect(
|
||||
const info = selectedDefault()
|
||||
return { id: info?.id ?? defaultID, info }
|
||||
}),
|
||||
list: Effect.fn("AgentV2.list")(function* () {
|
||||
list: Effect.fn("Agent.list")(function* () {
|
||||
return Array.fromIterable(state.get().agents.values())
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
|
||||
+20
-20
@@ -32,8 +32,8 @@ import {
|
||||
import { Auth, Endpoint, type AnyRoute } from "@opencode-ai/ai/route"
|
||||
import { ProviderShared } from "@opencode-ai/ai/protocols/shared"
|
||||
import { Cause, Context, Effect, Layer, Option, Schema, Scope, Stream } from "effect"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import type { ID, Info } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { State } from "./state"
|
||||
|
||||
type SDK = any
|
||||
@@ -42,14 +42,14 @@ type AssistantContent = Extract<LanguageModelV3Message, { role: "assistant" }>["
|
||||
type ToolResultContent = Extract<AssistantContent[number], { type: "tool-result" }>
|
||||
|
||||
export interface SDKEvent {
|
||||
readonly model: ModelV2.Info
|
||||
readonly model: Info
|
||||
readonly package: string
|
||||
readonly options: Record<string, any>
|
||||
sdk?: SDK
|
||||
}
|
||||
|
||||
export interface LanguageEvent {
|
||||
readonly model: ModelV2.Info
|
||||
readonly model: Info
|
||||
readonly sdk: SDK
|
||||
readonly options: Record<string, any>
|
||||
language?: LanguageModelV3
|
||||
@@ -103,7 +103,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) {
|
||||
})
|
||||
}
|
||||
|
||||
function prepareOptions(model: ModelV2.Info, pkg: string) {
|
||||
function prepareOptions(model: Info, pkg: string) {
|
||||
const projected = mapBodyToProviderOptions(model, pkg)
|
||||
const options: Record<string, any> = {
|
||||
name: model.providerID,
|
||||
@@ -146,7 +146,7 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
|
||||
if (typeof opts.body === "string" && model.body !== undefined) {
|
||||
const decoded = Option.getOrUndefined(Schema.decodeUnknownOption(Schema.UnknownFromJsonString)(opts.body))
|
||||
if (Schema.is(Schema.Record(Schema.String, Schema.Json))(decoded)) {
|
||||
opts.body = JSON.stringify(ProviderV2.mergeOverlay(decoded, model.body))
|
||||
opts.body = JSON.stringify(Provider.mergeOverlay(decoded, model.body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,11 +162,11 @@ function prepareOptions(model: ModelV2.Info, pkg: string) {
|
||||
}
|
||||
|
||||
export class InitError extends Schema.TaggedErrorClass<InitError>()("AISDK.InitError", {
|
||||
providerID: ProviderV2.ID,
|
||||
providerID: Provider.ID,
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
|
||||
function initError(providerID: ProviderV2.ID) {
|
||||
function initError(providerID: Provider.ID) {
|
||||
return Effect.catchCause((cause) => Effect.fail(new InitError({ providerID, cause: Cause.squash(cause) })))
|
||||
}
|
||||
|
||||
@@ -181,11 +181,11 @@ export interface Interface {
|
||||
}
|
||||
readonly runSDK: (event: SDKEvent) => Effect.Effect<SDKEvent>
|
||||
readonly runLanguage: (event: LanguageEvent) => Effect.Effect<LanguageEvent>
|
||||
readonly language: (model: ModelV2.Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: ModelV2.Info) => Effect.Effect<Model, InitError>
|
||||
readonly language: (model: Info) => Effect.Effect<LanguageModelV3, InitError>
|
||||
readonly model: (model: Info) => Effect.Effect<Model, InitError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/AISDK") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AISDK") {}
|
||||
|
||||
export const locationLayer = Layer.effect(
|
||||
Service,
|
||||
@@ -260,13 +260,13 @@ export const locationLayer = Layer.effect(
|
||||
})
|
||||
const existing = languages.get(key)
|
||||
if (existing) return existing
|
||||
if (!ProviderV2.isAISDK(model.package))
|
||||
if (!Provider.isAISDK(model.package))
|
||||
return yield* new InitError({
|
||||
providerID: model.providerID,
|
||||
cause: new Error(`Unsupported package ${model.package}`),
|
||||
})
|
||||
|
||||
const packageName = ProviderV2.packageName(model.package)
|
||||
const packageName = Provider.packageName(model.package)
|
||||
const options = prepareOptions(model, packageName)
|
||||
const sdkKey = cacheKey({
|
||||
providerID: model.providerID,
|
||||
@@ -301,8 +301,8 @@ export const locationLayer = Layer.effect(
|
||||
|
||||
export const defaultLayer = locationLayer
|
||||
|
||||
function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||
const packageName = ProviderV2.packageName(info.package!)
|
||||
function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
const packageName = Provider.packageName(info.package!)
|
||||
const projected = mapBodyToProviderOptions(info, packageName)
|
||||
const optionKey = providerOptionKey(packageName, info.providerID)
|
||||
const providerOptions = (() => {
|
||||
@@ -352,7 +352,7 @@ function modelFromLanguage(info: ModelV2.Info, language: LanguageModelV3) {
|
||||
})
|
||||
}
|
||||
|
||||
function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<string, unknown>>) {
|
||||
function gatewayProviderOptions(modelID: ID, settings: Readonly<Record<string, unknown>>) {
|
||||
const gateway =
|
||||
typeof settings.gateway === "object" && settings.gateway !== null && !Array.isArray(settings.gateway)
|
||||
? Object.fromEntries(Object.entries(settings.gateway))
|
||||
@@ -369,7 +369,7 @@ function gatewayProviderOptions(modelID: ModelV2.ID, settings: Readonly<Record<s
|
||||
return { gateway: model }
|
||||
}
|
||||
|
||||
function providerOptionKey(packageName: string | undefined, providerID: ProviderV2.ID) {
|
||||
function providerOptionKey(packageName: string | undefined, providerID: Provider.ID) {
|
||||
if (packageName === "@ai-sdk/google") return "google"
|
||||
if (packageName === "@ai-sdk/google-vertex") return "vertex"
|
||||
if (packageName === "@ai-sdk/google-vertex/anthropic") return "anthropic"
|
||||
@@ -395,18 +395,18 @@ function requestSettings(settings: Readonly<Record<string, unknown>> | undefined
|
||||
return Object.keys(result).length === 0 ? undefined : result
|
||||
}
|
||||
|
||||
function mapBodyToProviderOptions(model: ModelV2.Info, packageName: string) {
|
||||
function mapBodyToProviderOptions(model: Info, packageName: string) {
|
||||
const settings = requestSettings(model.settings)
|
||||
const pro = Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(model.body?.reasoning)
|
||||
const forceReasoning =
|
||||
["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(packageName) &&
|
||||
(pro || settings?.reasoningEffort !== undefined || settings?.reasoningSummary !== undefined)
|
||||
const normalized = forceReasoning ? ProviderV2.mergeOverlay(settings, { forceReasoning: true }) : settings
|
||||
const normalized = forceReasoning ? Provider.mergeOverlay(settings, { forceReasoning: true }) : settings
|
||||
if (!pro) return { settings: normalized, body: model.body }
|
||||
const body = { ...model.body }
|
||||
delete body.reasoning
|
||||
return {
|
||||
settings: ProviderV2.mergeOverlay(normalized, { reasoningMode: "pro" }),
|
||||
settings: Provider.mergeOverlay(normalized, { reasoningMode: "pro" }),
|
||||
body: Object.keys(body).length === 0 ? undefined : body,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export * as EventV2 from "./event"
|
||||
export * as Bus from "./bus"
|
||||
|
||||
import { Cause, Context, DateTime, Effect, Layer, Option, PubSub, Schema, Stream } from "effect"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||
import type { EventLog } from "@opencode-ai/schema/event-log"
|
||||
import { and, asc, eq, gt, inArray, lte, sql } from "drizzle-orm"
|
||||
import { Database } from "./database/database"
|
||||
@@ -12,18 +11,10 @@ import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import { Durable } from "@opencode-ai/schema/durable-event-manifest"
|
||||
|
||||
export const ID = Event.ID
|
||||
export type ID = import("@opencode-ai/schema/event").ID
|
||||
export const Seq = Event.Seq
|
||||
export type Seq = import("@opencode-ai/schema/event").Seq
|
||||
export const Version = Event.Version
|
||||
export type Version = import("@opencode-ai/schema/event").Version
|
||||
export type { Data, Definition, Payload } from "@opencode-ai/schema/event"
|
||||
|
||||
export type Subscriber<D extends Definition = Definition> = (event: Payload<D>) => Effect.Effect<void>
|
||||
export type Subscriber<D extends Event.Definition = Event.Definition> = (event: Event.Payload<D>) => Effect.Effect<void>
|
||||
export type Unsubscribe = Effect.Effect<void>
|
||||
|
||||
export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
|
||||
export const latestSequence = Effect.fn("Bus.latestSequence")(function* (
|
||||
db: Database.Interface["db"],
|
||||
aggregateID: string,
|
||||
) {
|
||||
@@ -36,7 +27,7 @@ export const latestSequence = Effect.fn("EventV2.latestSequence")(function* (
|
||||
return row?.seq ?? -1
|
||||
})
|
||||
|
||||
export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
|
||||
export const reserveSequence = Effect.fn("Bus.reserveSequence")(function* (
|
||||
db: Database.Interface["db"],
|
||||
aggregateID: string,
|
||||
seq: number,
|
||||
@@ -53,7 +44,7 @@ export const reserveSequence = Effect.fn("EventV2.reserveSequence")(function* (
|
||||
})
|
||||
|
||||
export type SerializedEvent = {
|
||||
readonly id: ID
|
||||
readonly id: Event.ID
|
||||
readonly type: string
|
||||
readonly created?: DateTime.Utc
|
||||
readonly seq: number
|
||||
@@ -62,7 +53,7 @@ export type SerializedEvent = {
|
||||
}
|
||||
|
||||
export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDurableEventError>()(
|
||||
"EventV2.InvalidDurableEvent",
|
||||
"Bus.InvalidDurableEvent",
|
||||
{
|
||||
type: Schema.String,
|
||||
message: Schema.String,
|
||||
@@ -71,11 +62,11 @@ export class InvalidDurableEventError extends Schema.TaggedErrorClass<InvalidDur
|
||||
|
||||
const envelope = (aggregateID: string, seq: number, version: number) => ({
|
||||
aggregateID,
|
||||
seq: Seq.make(seq),
|
||||
version: Version.make(version),
|
||||
seq: Event.Seq.make(seq),
|
||||
version: Event.Version.make(version),
|
||||
})
|
||||
|
||||
const decodeSerializedEvent = (event: SerializedEvent): Payload => {
|
||||
const decodeSerializedEvent = (event: SerializedEvent): Event.Payload => {
|
||||
const definition = Durable.get(event.type)
|
||||
if (!definition?.durable) {
|
||||
throw new InvalidDurableEventError({ type: event.type, message: `Unknown durable event type ${event.type}` })
|
||||
@@ -94,7 +85,7 @@ export const durable = Event.durable
|
||||
export const ephemeral = Event.ephemeral
|
||||
|
||||
export interface PublishOptions {
|
||||
readonly id?: ID
|
||||
readonly id?: Event.ID
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly location?: Location.Ref
|
||||
/** Local operational projection committed atomically with a new durable event. Not replayed or serialized. */
|
||||
@@ -102,13 +93,13 @@ export interface PublishOptions {
|
||||
}
|
||||
|
||||
/** Marker/event union emitted by `log`. */
|
||||
export type LogItem = Payload | EventLog.Synced
|
||||
export type LogItem = Event.Payload | EventLog.Synced
|
||||
|
||||
export const isSynced = (item: LogItem): item is EventLog.Synced => item.type === "log.synced"
|
||||
|
||||
export type SubscribePayload<D extends readonly Definition[]> = D[number] extends infer Item
|
||||
? Item extends Definition
|
||||
? Payload<Item>
|
||||
export type SubscribePayload<D extends readonly Event.Definition[]> = D[number] extends infer Item
|
||||
? Item extends Event.Definition
|
||||
? Event.Payload<Item>
|
||||
: never
|
||||
: never
|
||||
|
||||
@@ -117,19 +108,19 @@ export interface Subscribe {
|
||||
* Volatile live channel: every event published from now on, nothing before or
|
||||
* across a disconnect. Consumers that need reliability combine it with `log`.
|
||||
*/
|
||||
(): Stream.Stream<Payload>
|
||||
<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
|
||||
<const D extends readonly [Definition, ...Definition[]]>(definitions: D): Stream.Stream<SubscribePayload<D>>
|
||||
(): Stream.Stream<Event.Payload>
|
||||
<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
|
||||
<const D extends readonly [Event.Definition, ...Event.Definition[]]>(definitions: D): Stream.Stream<SubscribePayload<D>>
|
||||
}
|
||||
|
||||
const isDefinition = (input: Definition | readonly Definition[]): input is Definition => !Array.isArray(input)
|
||||
const isDefinition = (input: Event.Definition | readonly Event.Definition[]): input is Event.Definition => !Array.isArray(input)
|
||||
|
||||
export interface Interface {
|
||||
readonly publish: <D extends Definition>(
|
||||
readonly publish: <D extends Event.Definition>(
|
||||
definition: D,
|
||||
data: Data<D>,
|
||||
data: Event.Data<D>,
|
||||
options?: PublishOptions,
|
||||
) => Effect.Effect<Payload<D>>
|
||||
) => Effect.Effect<Event.Payload<D>>
|
||||
readonly subscribe: Subscribe
|
||||
/**
|
||||
* Durable, ordered per-aggregate log read. Forked aggregates may reserve an
|
||||
@@ -144,10 +135,10 @@ export interface Interface {
|
||||
readonly follow?: boolean
|
||||
}) => Stream.Stream<LogItem>
|
||||
/** Latest committed seq per aggregate. Aggregates without events are absent. */
|
||||
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Seq>>
|
||||
readonly sequences: (aggregateIDs: ReadonlyArray<string>) => Effect.Effect<ReadonlyMap<string, Event.Seq>>
|
||||
/** @deprecated Use `subscribe()` and consume the returned stream. */
|
||||
readonly listen: (listener: Subscriber) => Effect.Effect<Unsubscribe>
|
||||
readonly project: <D extends Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||
readonly project: <D extends Event.Definition>(definition: D, projector: Subscriber<D>) => Effect.Effect<void>
|
||||
readonly replay: (
|
||||
event: SerializedEvent,
|
||||
options?: { readonly publish?: boolean; readonly ownerID?: string; readonly strictOwner?: boolean },
|
||||
@@ -160,7 +151,8 @@ export interface Interface {
|
||||
readonly claim: (aggregateID: string, ownerID: string) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Event") {}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Bus") {}
|
||||
|
||||
export interface LayerOptions {
|
||||
readonly beforeAggregateRead?: (aggregateID: string) => Effect.Effect<void>
|
||||
@@ -173,20 +165,20 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const pubsub = {
|
||||
live: yield* PubSub.unbounded<Payload>(),
|
||||
live: yield* PubSub.unbounded<Event.Payload>(),
|
||||
durable: new Map<string, Set<PubSub.PubSub<void>>>(),
|
||||
typed: new Map<string, PubSub.PubSub<Payload>>(),
|
||||
typed: new Map<string, PubSub.PubSub<Event.Payload>>(),
|
||||
}
|
||||
const projectors = new Map<string, Subscriber[]>()
|
||||
const listeners = new Array<Subscriber>()
|
||||
const { db } = yield* Database.Service
|
||||
const logReadPageSize = options?.logReadPageSize ?? 512
|
||||
|
||||
const getOrCreate = (definition: Definition) =>
|
||||
const getOrCreate = (definition: Event.Definition) =>
|
||||
Effect.gen(function* () {
|
||||
const existing = pubsub.typed.get(definition.type)
|
||||
if (existing) return existing
|
||||
const created = yield* PubSub.unbounded<Payload>()
|
||||
const created = yield* PubSub.unbounded<Event.Payload>()
|
||||
pubsub.typed.set(definition.type, created)
|
||||
return created
|
||||
})
|
||||
@@ -204,8 +196,8 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
)
|
||||
|
||||
function commitDurableEvent(
|
||||
definition: Definition,
|
||||
event: Payload,
|
||||
definition: Event.Definition,
|
||||
event: Event.Payload,
|
||||
input?: {
|
||||
readonly seq: number
|
||||
readonly aggregateID: string
|
||||
@@ -318,7 +310,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
const committed = {
|
||||
...event,
|
||||
durable: { aggregateID, seq, version: durable.version },
|
||||
} as Payload
|
||||
} as Event.Payload
|
||||
for (const projector of list) {
|
||||
yield* projector(committed)
|
||||
}
|
||||
@@ -369,7 +361,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
})
|
||||
}
|
||||
|
||||
function publishEvent<D extends Definition>(definition: D, event: Payload<D>, commit?: PublishOptions["commit"]) {
|
||||
function publishEvent<D extends Event.Definition>(definition: D, event: Event.Payload<D>, commit?: PublishOptions["commit"]) {
|
||||
return Effect.gen(function* () {
|
||||
if (!definition?.durable && commit)
|
||||
return yield* Effect.die(
|
||||
@@ -379,22 +371,22 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
}),
|
||||
)
|
||||
if (definition?.durable) {
|
||||
const committed = yield* commitDurableEvent(definition, event as Payload, undefined, commit)
|
||||
const committed = yield* commitDurableEvent(definition, event as Event.Payload, undefined, commit)
|
||||
if (committed) {
|
||||
event = {
|
||||
...event,
|
||||
durable: envelope(committed.aggregateID, committed.seq, definition.durable.version),
|
||||
}
|
||||
yield* notify(event as Payload, true)
|
||||
yield* notify(event as Event.Payload, true)
|
||||
return event
|
||||
}
|
||||
}
|
||||
yield* notify(event as Payload, false)
|
||||
yield* notify(event as Event.Payload, false)
|
||||
return event
|
||||
})
|
||||
}
|
||||
|
||||
const observe = (event: Payload, observer: (event: Payload) => Effect.Effect<void>) =>
|
||||
const observe = (event: Event.Payload, observer: (event: Event.Payload) => Effect.Effect<void>) =>
|
||||
Effect.suspend(() => observer(event)).pipe(
|
||||
Effect.catchCauseIf(
|
||||
(cause) => !Cause.hasInterrupts(cause),
|
||||
@@ -402,7 +394,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
),
|
||||
)
|
||||
|
||||
function notify(event: Payload, isolateListeners: boolean) {
|
||||
function notify(event: Event.Payload, isolateListeners: boolean) {
|
||||
return Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
listeners,
|
||||
@@ -415,7 +407,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
})
|
||||
}
|
||||
|
||||
function publish<D extends Definition>(definition: D, data: Data<D>, options?: PublishOptions) {
|
||||
function publish<D extends Event.Definition>(definition: D, data: Event.Data<D>, options?: PublishOptions) {
|
||||
return Effect.gen(function* () {
|
||||
const serviceLocation = Option.getOrUndefined(yield* Effect.serviceOption(Location.Service))
|
||||
const location =
|
||||
@@ -426,13 +418,13 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
return yield* publishEvent(
|
||||
definition,
|
||||
{
|
||||
id: options?.id ?? ID.create(),
|
||||
id: options?.id ?? Event.ID.create(),
|
||||
created: yield* DateTime.now,
|
||||
...(options?.metadata ? { metadata: options.metadata } : {}),
|
||||
type: definition.type,
|
||||
...(location ? { location } : {}),
|
||||
data,
|
||||
} as Payload<D>,
|
||||
} as Event.Payload<D>,
|
||||
options?.commit,
|
||||
)
|
||||
})
|
||||
@@ -454,7 +446,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
created: event.created ?? DateTime.makeUnsafe(0),
|
||||
type: definition.type,
|
||||
data: Schema.decodeUnknownSync(definition.data)(event.data),
|
||||
} as Payload
|
||||
} as Event.Payload
|
||||
const committed = yield* commitDurableEvent(definition, payload, {
|
||||
seq: event.seq,
|
||||
aggregateID: event.aggregateID,
|
||||
@@ -516,7 +508,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
.pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
const local = <A extends Payload>(stream: Stream.Stream<A>) =>
|
||||
const local = <A extends Event.Payload>(stream: Stream.Stream<A>) =>
|
||||
Stream.unwrap(
|
||||
Effect.serviceOption(Location.Service).pipe(
|
||||
Effect.map((location) =>
|
||||
@@ -536,12 +528,12 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
),
|
||||
)
|
||||
|
||||
function subscribe(): Stream.Stream<Payload>
|
||||
function subscribe<D extends Definition>(definition: D): Stream.Stream<Payload<D>>
|
||||
function subscribe<const D extends readonly [Definition, ...Definition[]]>(
|
||||
function subscribe(): Stream.Stream<Event.Payload>
|
||||
function subscribe<D extends Event.Definition>(definition: D): Stream.Stream<Event.Payload<D>>
|
||||
function subscribe<const D extends readonly [Event.Definition, ...Event.Definition[]]>(
|
||||
definitions: D,
|
||||
): Stream.Stream<SubscribePayload<D>>
|
||||
function subscribe(input?: Definition | readonly Definition[]): Stream.Stream<Payload> {
|
||||
function subscribe(input?: Event.Definition | readonly Event.Definition[]): Stream.Stream<Event.Payload> {
|
||||
if (input === undefined) return streamLive()
|
||||
if (isDefinition(input)) {
|
||||
return local(Stream.unwrap(getOrCreate(input).pipe(Effect.map((pubsub) => Stream.fromPubSub(pubsub)))))
|
||||
@@ -550,7 +542,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
return streamLive().pipe(Stream.filter((event) => types.has(event.type)))
|
||||
}
|
||||
|
||||
const streamLive = (): Stream.Stream<Payload> => local(Stream.fromPubSub(pubsub.live))
|
||||
const streamLive = (): Stream.Stream<Event.Payload> => local(Stream.fromPubSub(pubsub.live))
|
||||
|
||||
const readAfter = (
|
||||
aggregateID: string,
|
||||
@@ -624,7 +616,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
let sequence = input.after ?? -1
|
||||
const readThrough = (through: number): Stream.Stream<Payload> =>
|
||||
const readThrough = (through: number): Stream.Stream<Event.Payload> =>
|
||||
Stream.paginate(sequence, (cursor) =>
|
||||
readAfter(input.aggregateID, cursor, { through, limit: logReadPageSize }).pipe(
|
||||
Effect.tap((page) =>
|
||||
@@ -648,7 +640,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
const marker: EventLog.Synced = {
|
||||
type: "log.synced",
|
||||
aggregateID: input.aggregateID,
|
||||
...(target >= 0 ? { seq: Seq.make(target) } : {}),
|
||||
...(target >= 0 ? { seq: Event.Seq.make(target) } : {}),
|
||||
}
|
||||
const replay: Stream.Stream<LogItem> = readThrough(target).pipe(
|
||||
Stream.map((event): LogItem => event),
|
||||
@@ -665,7 +657,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
}),
|
||||
)
|
||||
|
||||
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Seq>> => {
|
||||
const sequences = (aggregateIDs: ReadonlyArray<string>): Effect.Effect<ReadonlyMap<string, Event.Seq>> => {
|
||||
if (aggregateIDs.length === 0) return Effect.succeed(new Map())
|
||||
return db
|
||||
.select({ aggregateID: EventSequenceTable.aggregate_id, seq: EventSequenceTable.seq })
|
||||
@@ -674,7 +666,7 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
.all()
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Seq.make(row.seq)]))),
|
||||
Effect.map((rows) => new Map(rows.map((row) => [row.aggregateID, Event.Seq.make(row.seq)]))),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -687,11 +679,11 @@ export const layerWith = (options?: LayerOptions) =>
|
||||
})
|
||||
})
|
||||
|
||||
const project = <D extends Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
||||
const project = <D extends Event.Definition>(definition: D, projector: Subscriber<D>): Effect.Effect<void> =>
|
||||
Effect.sync(() => {
|
||||
const key = definition.durable ? versionedType(definition.type, definition.durable.version) : definition.type
|
||||
const list = projectors.get(key) ?? []
|
||||
list.push((event) => projector(event as Payload<D>))
|
||||
list.push((event) => projector(event as Event.Payload<D>))
|
||||
projectors.set(key, list)
|
||||
})
|
||||
|
||||
@@ -3,82 +3,82 @@ export * as Catalog from "./catalog"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Array, Context, Effect, Layer, Order, pipe } from "effect"
|
||||
import { Catalog } from "@opencode-ai/schema/catalog"
|
||||
import { ModelV2 } from "./model"
|
||||
import { ProviderV2 } from "./provider"
|
||||
import { EventV2 } from "./event"
|
||||
import { Model } from "./model"
|
||||
import { Provider } from "./provider"
|
||||
import { Bus } from "./bus"
|
||||
import { State } from "./state"
|
||||
import { Integration } from "./integration"
|
||||
|
||||
export type ProviderRecord = {
|
||||
provider: ProviderV2.MutableInfo
|
||||
models: Map<ModelV2.ID, ModelV2.MutableInfo>
|
||||
provider: Provider.MutableInfo
|
||||
models: Map<Model.ID, Model.MutableInfo>
|
||||
}
|
||||
|
||||
export type DefaultModel = { providerID: ProviderV2.ID; modelID: ModelV2.ID }
|
||||
export type DefaultModel = { providerID: Provider.ID; modelID: Model.ID }
|
||||
|
||||
export const Event = Catalog.Event
|
||||
export { Event } from "@opencode-ai/schema/catalog"
|
||||
|
||||
type Data = {
|
||||
providers: Map<ProviderV2.ID, ProviderRecord>
|
||||
providers: Map<Provider.ID, ProviderRecord>
|
||||
defaultModel?: DefaultModel
|
||||
}
|
||||
|
||||
export type Draft = {
|
||||
provider: {
|
||||
list: () => readonly ProviderRecord[]
|
||||
get: (providerID: ProviderV2.ID) => ProviderRecord | undefined
|
||||
update: (providerID: ProviderV2.ID, fn: (provider: ProviderV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID) => void
|
||||
get: (providerID: Provider.ID) => ProviderRecord | undefined
|
||||
update: (providerID: Provider.ID, fn: (provider: Provider.MutableInfo) => void) => void
|
||||
remove: (providerID: Provider.ID) => void
|
||||
}
|
||||
model: {
|
||||
get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => ModelV2.Info | undefined
|
||||
update: (providerID: ProviderV2.ID, modelID: ModelV2.ID, fn: (model: ModelV2.MutableInfo) => void) => void
|
||||
remove: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||
get: (providerID: Provider.ID, modelID: Model.ID) => Model.Info | undefined
|
||||
update: (providerID: Provider.ID, modelID: Model.ID, fn: (model: Model.MutableInfo) => void) => void
|
||||
remove: (providerID: Provider.ID, modelID: Model.ID) => void
|
||||
default: {
|
||||
get: () => DefaultModel | undefined
|
||||
set: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => void
|
||||
set: (providerID: Provider.ID, modelID: Model.ID) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Draft> {
|
||||
readonly provider: {
|
||||
readonly get: (providerID: ProviderV2.ID) => Effect.Effect<ProviderV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ProviderV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ProviderV2.Info[]>
|
||||
readonly get: (providerID: Provider.ID) => Effect.Effect<Provider.Info | undefined>
|
||||
readonly all: () => Effect.Effect<Provider.Info[]>
|
||||
readonly available: () => Effect.Effect<Provider.Info[]>
|
||||
}
|
||||
readonly model: {
|
||||
readonly get: (providerID: ProviderV2.ID, modelID: ModelV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly all: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly available: () => Effect.Effect<ModelV2.Info[]>
|
||||
readonly default: () => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly small: (providerID: ProviderV2.ID) => Effect.Effect<ModelV2.Info | undefined>
|
||||
readonly get: (providerID: Provider.ID, modelID: Model.ID) => Effect.Effect<Model.Info | undefined>
|
||||
readonly all: () => Effect.Effect<Model.Info[]>
|
||||
readonly available: () => Effect.Effect<Model.Info[]>
|
||||
readonly default: () => Effect.Effect<Model.Info | undefined>
|
||||
readonly small: (providerID: Provider.ID) => Effect.Effect<Model.Info | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Catalog") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Catalog") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const bus = yield* Bus.Service
|
||||
const integrations = yield* Integration.Service
|
||||
|
||||
const available = (provider: ProviderV2.Info, integration: Integration.Info | undefined) => {
|
||||
const available = (provider: Provider.Info, integration: Integration.Info | undefined) => {
|
||||
if (provider.disabled) return false
|
||||
if (typeof provider.settings?.apiKey === "string") return true
|
||||
if (integration?.connections.length) return true
|
||||
return provider.integrationID === undefined && !integration
|
||||
}
|
||||
|
||||
const projectModel = (model: ModelV2.Info, provider: ProviderV2.Info) => {
|
||||
const projectModel = (model: Model.Info, provider: Provider.Info) => {
|
||||
return {
|
||||
...model,
|
||||
package: model.package ?? provider.package,
|
||||
settings: ProviderV2.mergeOverlay(provider.settings, model.settings),
|
||||
headers: ProviderV2.mergeHeaders(provider.headers, model.headers),
|
||||
body: ProviderV2.mergeOverlay(provider.body, model.body),
|
||||
} satisfies ModelV2.Info
|
||||
settings: Provider.mergeOverlay(provider.settings, model.settings),
|
||||
headers: Provider.mergeHeaders(provider.headers, model.headers),
|
||||
body: Provider.mergeOverlay(provider.body, model.body),
|
||||
} satisfies Model.Info
|
||||
}
|
||||
|
||||
const state = State.create<Data, Draft>({
|
||||
@@ -93,8 +93,8 @@ const layer = Layer.effect(
|
||||
let current = draft.providers.get(providerID)
|
||||
if (!current) {
|
||||
current = {
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
|
||||
models: new Map<Model.ID, Model.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, current)
|
||||
}
|
||||
@@ -110,13 +110,13 @@ const layer = Layer.effect(
|
||||
let record = draft.providers.get(providerID)
|
||||
if (!record) {
|
||||
record = {
|
||||
provider: ProviderV2.Info.empty(providerID) as ProviderV2.MutableInfo,
|
||||
models: new Map<ModelV2.ID, ModelV2.MutableInfo>(),
|
||||
provider: Provider.Info.empty(providerID) as Provider.MutableInfo,
|
||||
models: new Map<Model.ID, Model.MutableInfo>(),
|
||||
}
|
||||
draft.providers.set(providerID, record)
|
||||
}
|
||||
const model =
|
||||
record.models.get(modelID) ?? (ModelV2.Info.default(providerID, modelID) as ModelV2.MutableInfo)
|
||||
record.models.get(modelID) ?? (Model.Info.default(providerID, modelID) as Model.MutableInfo)
|
||||
if (!record.models.has(modelID)) record.models.set(modelID, model)
|
||||
fn(model)
|
||||
model.id = modelID
|
||||
@@ -135,8 +135,8 @@ const layer = Layer.effect(
|
||||
}
|
||||
return result
|
||||
},
|
||||
finalize: Effect.fn("CatalogV2.finalize")(function* (catalog) {
|
||||
yield* events.publish(Event.Updated, {})
|
||||
finalize: Effect.fn("Catalog.finalize")(function* (catalog) {
|
||||
yield* bus.publish(Catalog.Event.Updated, {})
|
||||
}),
|
||||
})
|
||||
const result: Interface = {
|
||||
@@ -144,15 +144,15 @@ const layer = Layer.effect(
|
||||
reload: state.reload,
|
||||
|
||||
provider: {
|
||||
get: Effect.fn("CatalogV2.provider.get")(function* (providerID) {
|
||||
get: Effect.fn("Catalog.provider.get")(function* (providerID) {
|
||||
return state.get().providers.get(providerID)?.provider
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.provider.all")(function* () {
|
||||
all: Effect.fn("Catalog.provider.all")(function* () {
|
||||
return Array.fromIterable(state.get().providers.values()).map((record) => record.provider)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.provider.available")(function* () {
|
||||
available: Effect.fn("Catalog.provider.available")(function* () {
|
||||
const active = new Map((yield* integrations.list()).map((integration) => [integration.id, integration]))
|
||||
return (yield* result.provider.all()).filter((provider) =>
|
||||
available(provider, active.get(provider.integrationID ?? Integration.ID.make(provider.id))),
|
||||
@@ -161,14 +161,14 @@ const layer = Layer.effect(
|
||||
},
|
||||
|
||||
model: {
|
||||
get: Effect.fn("CatalogV2.model.get")(function* (providerID, modelID) {
|
||||
get: Effect.fn("Catalog.model.get")(function* (providerID, modelID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const model = record.models.get(modelID)
|
||||
return model && projectModel(model, record.provider)
|
||||
}),
|
||||
|
||||
all: Effect.fn("CatalogV2.model.all")(function* () {
|
||||
all: Effect.fn("Catalog.model.all")(function* () {
|
||||
return pipe(
|
||||
Array.fromIterable(state.get().providers.values()),
|
||||
Array.flatMap((record) => {
|
||||
@@ -178,9 +178,9 @@ const layer = Layer.effect(
|
||||
)
|
||||
}),
|
||||
|
||||
available: Effect.fn("CatalogV2.model.available")(function* () {
|
||||
available: Effect.fn("Catalog.model.available")(function* () {
|
||||
const providers = new Set((yield* result.provider.available()).map((provider) => provider.id))
|
||||
const models: ModelV2.Info[] = []
|
||||
const models: Model.Info[] = []
|
||||
for (const record of state.get().providers.values()) {
|
||||
if (!providers.has(record.provider.id)) continue
|
||||
for (const model of record.models.values()) {
|
||||
@@ -194,7 +194,7 @@ const layer = Layer.effect(
|
||||
)
|
||||
}),
|
||||
|
||||
default: Effect.fn("CatalogV2.model.default")(function* () {
|
||||
default: Effect.fn("Catalog.model.default")(function* () {
|
||||
const defaultModel = state.get().defaultModel
|
||||
if (defaultModel) {
|
||||
const provider = yield* result.provider.get(defaultModel.providerID)
|
||||
@@ -207,18 +207,18 @@ const layer = Layer.effect(
|
||||
return (yield* result.model.available())[0]
|
||||
}),
|
||||
|
||||
small: Effect.fn("CatalogV2.model.small")(function* (providerID) {
|
||||
small: Effect.fn("Catalog.model.small")(function* (providerID) {
|
||||
const record = state.get().providers.get(providerID)
|
||||
if (!record) return
|
||||
const provider = record.provider
|
||||
|
||||
// TODO: Remove these provider-specific assumptions once model syncing reliably reports available deployments.
|
||||
if (providerID === ProviderV2.ID.azure || providerID === ProviderV2.ID.make("azure-cognitive-services")) {
|
||||
if (providerID === Provider.ID.azure || providerID === Provider.ID.make("azure-cognitive-services")) {
|
||||
return
|
||||
}
|
||||
|
||||
if (providerID === ProviderV2.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(ModelV2.ID.make("gpt-5-nano"))
|
||||
if (providerID === Provider.ID.opencode) {
|
||||
const gpt5Nano = record.models.get(Model.ID.make("gpt-5-nano"))
|
||||
if (gpt5Nano?.enabled && gpt5Nano.status === "active") return projectModel(gpt5Nano, provider)
|
||||
}
|
||||
|
||||
@@ -268,4 +268,4 @@ const layer = Layer.effect(
|
||||
|
||||
const SMALL_MODEL_RE = /\b(nano|flash|lite|mini|haiku|small|fast)\b/
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node, Integration.node] })
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node, Integration.node] })
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
export * as CodeMode from "./codemode"
|
||||
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { CodeModeCatalog } from "./codemode/catalog"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { PermissionV2 } from "./permission"
|
||||
import { ExecuteTool } from "./tool/execute"
|
||||
import type { Any, Registration } from "./tool/tool"
|
||||
import { Wildcard } from "./util/wildcard"
|
||||
|
||||
export interface Materialization {
|
||||
readonly tool?: Any
|
||||
readonly catalog?: ReadonlyArray<CodeModeCatalog.Entry>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly register: (
|
||||
registrations: ReadonlyArray<Registration & { readonly key: string }>,
|
||||
) => Effect.Effect<void, never, Scope.Scope>
|
||||
readonly materialize: (permissions?: PermissionV2.Ruleset) => Effect.Effect<Materialization>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/CodeMode") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const local = new Map<string, Array<{ readonly token: object; readonly registration: ExecuteTool.Registration }>>()
|
||||
|
||||
return Service.of({
|
||||
register: Effect.fn("CodeMode.register")(function* (registrations) {
|
||||
if (registrations.length === 0) return
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const token = {}
|
||||
for (const registration of registrations)
|
||||
local.set(registration.key, [
|
||||
...(local.get(registration.key) ?? []),
|
||||
{
|
||||
token,
|
||||
registration,
|
||||
},
|
||||
])
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
for (const registration of registrations) {
|
||||
const remaining = local.get(registration.key)?.filter((item) => item.token !== token) ?? []
|
||||
if (remaining.length > 0) local.set(registration.key, remaining)
|
||||
else local.delete(registration.key)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
materialize: Effect.fn("CodeMode.materialize")(function* (permissions) {
|
||||
const registrations = new Map<string, ExecuteTool.Registration>()
|
||||
const rules = permissions ?? []
|
||||
for (const [name, entries] of local) {
|
||||
const registration = entries.at(-1)?.registration
|
||||
if (!registration) continue
|
||||
const rule = rules.findLast((rule) => Wildcard.match(registration.permission, rule.action))
|
||||
if (rule?.resource === "*" && rule.effect === "deny") continue
|
||||
registrations.set(name, registration)
|
||||
}
|
||||
if (registrations.size === 0) return {}
|
||||
const executeRule = rules.findLast((rule) => Wildcard.match("execute", rule.action))
|
||||
if (executeRule?.resource === "*" && executeRule.effect === "deny") return {}
|
||||
return {
|
||||
tool: ExecuteTool.create(registrations),
|
||||
catalog: ExecuteTool.catalog(registrations),
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [] })
|
||||
@@ -6,22 +6,19 @@ import { Instructions } from "../instructions/index"
|
||||
import { CodeModeCatalog } from "./catalog"
|
||||
|
||||
// prettier-ignore
|
||||
const prompt = (hasMoreTools: boolean) => `Run JavaScript to orchestrate tool calls and compose their results. Imports, direct filesystem access, and timers are unavailable. Do not use \`fetch\`; all external access goes through \`tools\`.
|
||||
|
||||
Prefer an explicit \`return\`; if omitted, the final top-level expression becomes the result. Await tool calls before returning; any calls still pending when execution ends are interrupted. Run independent calls concurrently with \`Promise.all\`.
|
||||
|
||||
Do not infer or normalize tool names; use only the exact signatures shown below${hasMoreTools ? " or returned by `search`" : ""}, preserving bracket notation such as \`tools.<namespace>["tool-name"](input)\`.${hasMoreTools ? `
|
||||
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
|
||||
|
||||
## Search
|
||||
|
||||
Only some tool signatures are shown. Use \`search\` to discover exact paths and signatures for additional tools:
|
||||
Use \`search\` to discover exact paths and signatures for additional tools:
|
||||
|
||||
- ${searchSignature}` : ""}
|
||||
|
||||
## Available tools`
|
||||
|
||||
export function render(catalog: CodeModeCatalog.Summary) {
|
||||
if (catalog.total === 0) return "No tools are currently available."
|
||||
if (catalog.total === 0)
|
||||
return "No Code Mode tools are currently available. Later Code Mode catalog updates may add or remove tools. Do not call `execute` unless there is at least one available Code Mode tool."
|
||||
|
||||
const tools = catalog.namespaces.flatMap((namespace) => {
|
||||
const count = namespace.count === 1 ? "1 tool" : `${namespace.count} tools`
|
||||
@@ -40,12 +37,13 @@ ${tools.join("\n")}`
|
||||
}
|
||||
|
||||
export function update(previous: CodeModeCatalog.Summary, current: CodeModeCatalog.Summary) {
|
||||
const full = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
|
||||
const replacement = `The Code Mode tool catalog has changed. This catalog supersedes the previous Code Mode tool catalog.
|
||||
|
||||
${render(current)}`
|
||||
if (current.total === 0) return replacement
|
||||
const previousComplete = previous.shown === previous.total
|
||||
const currentComplete = current.shown === current.total
|
||||
if (previousComplete !== currentComplete) return full
|
||||
if (previousComplete !== currentComplete) return replacement
|
||||
|
||||
const diff = Instructions.diffByKey(
|
||||
previous.namespaces.flatMap((namespace) => namespace.entries),
|
||||
@@ -56,7 +54,7 @@ ${render(current)}`
|
||||
const entriesChanged = diff.added.length > 0 || diff.removed.length > 0 || diff.changed.length > 0
|
||||
|
||||
if (!currentComplete) {
|
||||
if (entriesChanged) return full
|
||||
if (entriesChanged) return replacement
|
||||
const namespaces = Instructions.diffByKey(
|
||||
previous.namespaces,
|
||||
current.namespaces,
|
||||
@@ -64,7 +62,7 @@ ${render(current)}`
|
||||
(before, after) => before.count !== after.count,
|
||||
)
|
||||
const changed = namespaces.added.length > 0 || namespaces.removed.length > 0 || namespaces.changed.length > 0
|
||||
if (!changed) return full
|
||||
if (!changed) return replacement
|
||||
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (namespaces.added.length > 0) {
|
||||
@@ -89,11 +87,11 @@ ${render(current)}`
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < full.length) return delta
|
||||
return full
|
||||
if (delta.length < replacement.length) return delta
|
||||
return replacement
|
||||
}
|
||||
|
||||
if (!entriesChanged) return full
|
||||
if (!entriesChanged) return replacement
|
||||
const parts = ["The Code Mode tool catalog has changed."]
|
||||
if (diff.added.length > 0) {
|
||||
parts.push(
|
||||
@@ -119,19 +117,19 @@ ${render(current)}`
|
||||
)
|
||||
}
|
||||
const delta = parts.join("\n\n")
|
||||
if (delta.length < full.length) return delta
|
||||
return full
|
||||
if (delta.length < replacement.length) return delta
|
||||
return replacement
|
||||
}
|
||||
|
||||
const key = Instructions.Key.make("core/codemode")
|
||||
const codec = Schema.toCodecJson(CodeModeCatalog.Summary)
|
||||
|
||||
export const make = (entries?: ReadonlyArray<CodeModeCatalog.Entry>): Instructions.Instructions => {
|
||||
const catalog = CodeModeCatalog.summarize(entries ?? [])
|
||||
const catalog = entries === undefined ? Instructions.removed : CodeModeCatalog.summarize(entries)
|
||||
return Instructions.make({
|
||||
key,
|
||||
codec,
|
||||
read: Effect.succeed(catalog.total === 0 ? Instructions.removed : catalog),
|
||||
read: Effect.succeed(catalog),
|
||||
render: {
|
||||
initial: render,
|
||||
changed: update,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
export * as ExecuteTool from "./execute"
|
||||
export type { Registration } from "./tool"
|
||||
export * as CodeModeTool from "./tool"
|
||||
|
||||
import { CodeMode, Tool, toolError } from "@opencode-ai/codemode"
|
||||
import type { ToolContent } from "@opencode-ai/ai"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { execute, make, toLLMDefinition, type Content, type Metadata, type Registration } from "./tool"
|
||||
import type { Content, Context, Error, Info, Metadata, Result } from "@opencode-ai/schema/tool"
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { definition } from "../tool/runtime"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -34,14 +33,25 @@ type CollectedFiles = {
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime through { code }.",
|
||||
"Call Code Mode tools through `tools` using the exact paths and signatures from the instructions.",
|
||||
"Use `search({ query })` to discover exact signatures when needed.",
|
||||
"Await important calls and use `Promise.all` for independent calls.",
|
||||
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
"Await every call whose completion matters; pending calls are interrupted when execution ends. Run independent calls concurrently with `Promise.all`.",
|
||||
].join("\n")
|
||||
|
||||
export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
return make({
|
||||
export const create = (
|
||||
registrations: ReadonlyMap<string, Info>,
|
||||
executeTool: (
|
||||
name: string,
|
||||
tool: Info,
|
||||
input: unknown,
|
||||
context: Context,
|
||||
) => Effect.Effect<Result, Error>,
|
||||
) => {
|
||||
return ({
|
||||
name: "execute",
|
||||
description,
|
||||
input: CodeMode.Input,
|
||||
output: ExecuteOutput,
|
||||
@@ -50,50 +60,51 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
const callIndex = yield* Ref.make(0)
|
||||
const files = yield* Ref.make<Array<CollectedFiles>>([])
|
||||
const calls = yield* Ref.make<Array<ExecuteCall>>([])
|
||||
// TODO: Publish live call-list updates once V2 has a generic tool progress API.
|
||||
const finalCalls = Ref.get(calls).pipe(
|
||||
Effect.map((items) =>
|
||||
items.map((call) => (call.status === "running" ? { ...call, status: "error" as const } : call)),
|
||||
),
|
||||
)
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
|
||||
lock.withPermit(
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
|
||||
)
|
||||
const result = yield* runtime(
|
||||
registrations,
|
||||
(name, registration, input) =>
|
||||
(name, tool, input) =>
|
||||
Effect.gen(function* () {
|
||||
const index = yield* Ref.getAndUpdate(callIndex, (index) => index + 1)
|
||||
const executed = yield* execute(registration.tool, input, {
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
progress: context.progress,
|
||||
}).pipe(Effect.mapError((failure) => toolError(failure.message, failure)))
|
||||
const outputFileParts = outputFiles(executed.content)
|
||||
const executed = yield* executeTool(name, tool, input, context).pipe(
|
||||
Effect.mapError((failure) => toolError(failure.message, failure)),
|
||||
)
|
||||
const content =
|
||||
typeof executed.content === "string"
|
||||
? [{ type: "text" as const, text: executed.content }]
|
||||
: executed.content ?? []
|
||||
const outputFileParts = outputFiles(content)
|
||||
if (outputFileParts.length > 0)
|
||||
yield* Ref.update(files, (items) => [...items, { index, files: outputFileParts }])
|
||||
return executed.output
|
||||
}),
|
||||
{
|
||||
onToolCallStart: ({ index, name, input }) =>
|
||||
Effect.gen(function* () {
|
||||
const shown = displayInput(input)
|
||||
yield* Ref.update(calls, (items) => {
|
||||
const next = [...items]
|
||||
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||
return next
|
||||
})
|
||||
}),
|
||||
onToolCallEnd: ({ index, outcome }) =>
|
||||
Ref.update(calls, (items) => {
|
||||
const current = items[index]
|
||||
if (!current) return items
|
||||
onToolCallStart: ({ index, name, input }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = { ...current, status: outcome === "success" ? "completed" : "error" }
|
||||
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||
return next
|
||||
}),
|
||||
})
|
||||
},
|
||||
onToolCallEnd: ({ index, name, input, outcome }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = {
|
||||
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
|
||||
status: outcome === "success" ? "completed" : "error",
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
},
|
||||
).execute(code)
|
||||
const toolCalls = yield* finalCalls
|
||||
const toolCalls = yield* Ref.get(calls)
|
||||
const collected = (yield* Ref.get(files))
|
||||
.toSorted((left, right) => left.index - right.index)
|
||||
.flatMap((item) => item.files)
|
||||
@@ -104,11 +115,11 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
files: collected,
|
||||
...(result.ok ? {} : { error: true }),
|
||||
}
|
||||
const content: [Content, ...Content[]] = [{ type: "text", text: value.output }]
|
||||
const content: Array<Content> = [{ type: "text", text: value.output }]
|
||||
content.push(
|
||||
...value.files.map((file) => ({
|
||||
type: "file" as const,
|
||||
data: file.data,
|
||||
uri: `data:${file.mime};base64,${file.data}`,
|
||||
mime: file.mime,
|
||||
...(file.name === undefined ? {} : { name: file.name }),
|
||||
})),
|
||||
@@ -123,23 +134,26 @@ export const create = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
metadata,
|
||||
}
|
||||
}),
|
||||
})
|
||||
}) satisfies Info
|
||||
}
|
||||
|
||||
export const catalog = (registrations: ReadonlyMap<string, Registration>) => {
|
||||
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
|
||||
}
|
||||
|
||||
function runtime(
|
||||
registrations: ReadonlyMap<string, Registration>,
|
||||
executeTool: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
registrations: ReadonlyMap<string, Info>,
|
||||
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
) {
|
||||
const tools: Record<string, Tool.Tool<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = toLLMDefinition(name, registration.tool)
|
||||
const child = definition(registration)
|
||||
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const path =
|
||||
registration.namespace === undefined ? registration.name : `${registration.namespace}.${registration.name}`
|
||||
registration.options?.namespace === undefined
|
||||
? normalized
|
||||
: `${registration.options.namespace}.${normalized}`
|
||||
tools[path] = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
@@ -177,7 +191,7 @@ function formatValue(value: CodeMode.DataValue) {
|
||||
return JSON.stringify(value, null, 2) ?? String(value)
|
||||
}
|
||||
|
||||
function outputFiles(content: ReadonlyArray<ToolContent>): Array<typeof ExecuteFile.Type> {
|
||||
function outputFiles(content: ReadonlyArray<Content>): Array<typeof ExecuteFile.Type> {
|
||||
return content.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const prefix = `data:${part.mime};base64,`
|
||||
@@ -1,11 +1,11 @@
|
||||
export * as CommandV2 from "./command"
|
||||
export * as Command from "./command"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema, Types } from "effect"
|
||||
import { Command } from "@opencode-ai/schema/command"
|
||||
import { State } from "./state"
|
||||
import { MCP } from "./mcp/index"
|
||||
import { EventV2 } from "./event"
|
||||
import { Bus } from "./bus"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { Config } from "./config"
|
||||
@@ -14,7 +14,7 @@ import { ShellSelect } from "./shell/select"
|
||||
|
||||
export const Info = Command.Info
|
||||
export type Info = Command.Info
|
||||
export const Event = Command.Event
|
||||
export { Event } from "@opencode-ai/schema/command"
|
||||
|
||||
export type Evaluation = {
|
||||
readonly text: string
|
||||
@@ -50,13 +50,13 @@ export interface Interface extends State.Transformable<Draft> {
|
||||
}) => Effect.Effect<Evaluation, NotFoundError | EvaluationError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Command") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Command") {}
|
||||
|
||||
export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const mcp = yield* MCP.Service
|
||||
const events = yield* EventV2.Service
|
||||
const bus = yield* Bus.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
@@ -76,7 +76,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
draft.commands.delete(name)
|
||||
},
|
||||
}),
|
||||
finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid),
|
||||
finalize: () => bus.publish(Command.Event.Updated, {}).pipe(Effect.asVoid),
|
||||
})
|
||||
const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined
|
||||
const mcpCommands = Effect.fnUntraced(function* () {
|
||||
@@ -92,12 +92,12 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
return Service.of({
|
||||
reload: state.reload,
|
||||
transform: state.transform,
|
||||
get: Effect.fn("CommandV2.get")(function* (name) {
|
||||
get: Effect.fn("Command.get")(function* (name) {
|
||||
const command = staticCommand(name)
|
||||
if (command) return command
|
||||
return (yield* mcpCommands()).find((command) => command.name === name)
|
||||
}),
|
||||
list: Effect.fn("CommandV2.list")(function* () {
|
||||
list: Effect.fn("Command.list")(function* () {
|
||||
const commands = Array.from(state.get().commands.values()) as Info[]
|
||||
const names = new Set(commands.map((command) => command.name))
|
||||
return [
|
||||
@@ -105,7 +105,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
...(yield* mcpCommands()).filter((command) => !names.has(command.name)),
|
||||
]
|
||||
}),
|
||||
evaluate: Effect.fn("CommandV2.evaluate")(function* (input) {
|
||||
evaluate: Effect.fn("Command.evaluate")(function* (input) {
|
||||
const command = staticCommand(input.name)
|
||||
if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", {
|
||||
config,
|
||||
@@ -247,7 +247,7 @@ export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node],
|
||||
deps: [MCP.node, Bus.node, AppProcess.node, Config.node, Location.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user