mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-25 01:57:38 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9a4baf9e | ||
|
|
9810d98bc2 | ||
|
|
2caba90a63 | ||
|
|
eccf0b3b7b | ||
|
|
808588e9b9 | ||
|
|
c35c211460 | ||
|
|
efb8e23dcc |
@@ -55,7 +55,7 @@ const request = LLM.request({
|
||||
prompt: "Say hello.",
|
||||
})
|
||||
|
||||
const response = yield * LLMClient.generate(request)
|
||||
const response = yield * LLMClient.generate(request) // inside Effect.gen
|
||||
```
|
||||
|
||||
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
|
||||
@@ -98,7 +98,7 @@ When a provider supports multiple physical transports, selection remains executi
|
||||
|
||||
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.make(...)` (`src/route/media.ts`) composes a `MediaProtocol` kind with `Endpoint` and `Auth` and owns the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; use `MediaProtocol.decodeJson` / `text` / `bytes` so decode failures retain the raw body and HTTP context. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`, `pollHint`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through `MediaInput.rejectUnsupported`.
|
||||
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `Generation.AwaitOptions` (`{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
|
||||
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `MediaProtocol.incomplete`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `MediaProtocol.decodeFrame` and raise stream-time failures with `MediaProtocol.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
|
||||
|
||||
|
||||
+171
-114
@@ -1,11 +1,10 @@
|
||||
# @opencode/ai
|
||||
|
||||
Schema-first language model and image-generation APIs built with Effect.
|
||||
Schema-first APIs for text, images, video, speech, and transcription, built with Effect.
|
||||
|
||||
```ts
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode/ai"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
import { Effect } from "effect"
|
||||
import { AIClient, LLM } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
|
||||
@@ -18,23 +17,23 @@ const request = LLM.request({
|
||||
})
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
const response = yield* LLM.generate(request)
|
||||
console.log(response.text)
|
||||
})
|
||||
|
||||
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
|
||||
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
|
||||
// Every modality client plus the HTTP request executor; `AIClient.layerWith(executor)` swaps the executor.
|
||||
await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
|
||||
```
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
Run `LLM.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
|
||||
Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
The same configured facade names image models. `Image.request` resolves the provider's image route from the ref and
|
||||
returns `Media.Asset`s with lazily decoded bytes:
|
||||
The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
|
||||
provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes:
|
||||
|
||||
```ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Image, ImageClient, Media } from "@opencode/ai"
|
||||
import { Image, Media } from "@opencode/ai"
|
||||
|
||||
const image = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
@@ -46,13 +45,28 @@ const image = Effect.gen(function* () {
|
||||
yield* Media.write(response.image, "./garden.png")
|
||||
})
|
||||
|
||||
// `asset.bytes()` / `Media.write` also need the executor, so merge it into the environment instead of hiding it.
|
||||
const imageLayer = ImageClient.layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
|
||||
|
||||
await Effect.runPromise(image.pipe(Effect.provide(imageLayer), Effect.provide(NodeFileSystem.layer)))
|
||||
// `Media.file` / `Media.write` use the Effect `FileSystem` service; provide your platform's layer.
|
||||
await Effect.runPromise(image.pipe(Effect.provide(AIClient.layer), Effect.provide(NodeFileSystem.layer)))
|
||||
```
|
||||
|
||||
Prefer promises? `@opencode/ai/promise` exposes the same LLM and image APIs over one managed runtime:
|
||||
Advanced: each client also has its own `layer`, which requires `RequestExecutor.Service`. Compose client layers with
|
||||
`Layer.provideMerge`, not `Layer.provide`: `asset.bytes()`, `Media.write`, and Gemini's `media` output parts need the
|
||||
executor too, and hiding it fails type-checking with `RequestExecutorService` left in the requirements.
|
||||
|
||||
To share a policy such as logging across every client, wrap the executor once with `RequestExecutor.middleware`:
|
||||
|
||||
```ts
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
|
||||
const logged = RequestExecutor.middleware((request, next) =>
|
||||
Effect.log(`${request.method} ${request.url}`).pipe(Effect.andThen(next(request))),
|
||||
)
|
||||
|
||||
const everything = AIClient.layerWith(logged) // or AI.make({ layer: logged })
|
||||
```
|
||||
|
||||
Prefer promises? `@opencode/ai/promise` exposes the same LLM and media APIs over one managed runtime, plus asset
|
||||
helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Effect `FileSystem` is needed:
|
||||
|
||||
```ts
|
||||
import { AI } from "@opencode/ai/promise"
|
||||
@@ -60,6 +74,7 @@ import { AI } from "@opencode/ai/promise"
|
||||
const ai = AI.make()
|
||||
const text = await ai.llm.generate({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." })
|
||||
const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
|
||||
await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset)
|
||||
for await (const event of ai.llm.stream({ model: openai.responses("gpt-4o-mini"), prompt: "Stream hello." })) {
|
||||
// LLMEvent
|
||||
}
|
||||
@@ -322,10 +337,9 @@ and `moonshot/responses`; each exports `model(modelID, settings)`.
|
||||
MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
|
||||
|
||||
```ts
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode/ai"
|
||||
import { Effect } from "effect"
|
||||
import { AIClient, LLM } from "@opencode/ai"
|
||||
import { MiniMax } from "@opencode/ai/providers"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
|
||||
const minimax = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY })
|
||||
const request = LLM.request({
|
||||
@@ -335,8 +349,7 @@ const request = LLM.request({
|
||||
generation: { maxTokens: 1536 },
|
||||
})
|
||||
|
||||
const layer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
const response = await Effect.runPromise(LLMClient.generate(request).pipe(Effect.provide(layer)))
|
||||
const response = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(AIClient.layer)))
|
||||
console.log(response.text)
|
||||
```
|
||||
|
||||
@@ -405,14 +418,14 @@ Use `Image.generate` for one-off generation or editing:
|
||||
import { Image, Media } from "@opencode/ai"
|
||||
|
||||
const generation = Image.generate({
|
||||
model: meta("muse-image-1.0"),
|
||||
model: meta.image("muse-image-1.0"),
|
||||
prompt: "A flat black square on a white background.",
|
||||
n: 1,
|
||||
providerOptions: { reasoningStrength: "low" },
|
||||
})
|
||||
|
||||
const edit = Image.generate({
|
||||
model: meta("muse-image-1.0"),
|
||||
model: meta.image("muse-image-1.0"),
|
||||
prompt: "Make the square purple.",
|
||||
images: [Media.bytes(imageBytes, "image/webp")],
|
||||
format: "png",
|
||||
@@ -459,6 +472,25 @@ const program = Effect.gen(function* () {
|
||||
})
|
||||
```
|
||||
|
||||
Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
|
||||
call rather than being dropped, so check this table before swapping only the `model`:
|
||||
|
||||
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
|
||||
| --------------------- | --- | --------- | ------------- | ------ | -------- | ------------------------- | ------------------- |
|
||||
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
|
||||
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
|
||||
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
|
||||
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
|
||||
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
|
||||
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
|
||||
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`) | ✓ |
|
||||
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
|
||||
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
|
||||
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
|
||||
|
||||
✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
|
||||
rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
|
||||
|
||||
`Media.Asset` is the one asset type shared by image requests, image responses, LLM messages, and tool results.
|
||||
`asset.source` is the serializable `Media.Source` (`bytes`, `base64`, `url`, or `ref`); `asset.bytes()`,
|
||||
`asset.base64()`, and `asset.dataUrl()` decode or download lazily and cache; `asset.materialize()` pulls a `url`
|
||||
@@ -468,9 +500,8 @@ asset into owned bytes before the provider URL expires. Construct assets with `M
|
||||
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
|
||||
|
||||
```ts
|
||||
const response =
|
||||
yield *
|
||||
Image.generate({
|
||||
const composed = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "Combine these product photos into one studio scene",
|
||||
images: [
|
||||
@@ -481,23 +512,25 @@ const response =
|
||||
providerOptions,
|
||||
http,
|
||||
})
|
||||
return response.images
|
||||
})
|
||||
```
|
||||
|
||||
`Media.ref(provider, id)` represents provider file handles such as OpenAI file IDs or Gemini Files URIs; routes
|
||||
only forward refs that belong to their own provider. Raw strings are not accepted as image inputs, avoiding
|
||||
ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
|
||||
non-empty array selects the provider's edit behavior without enforcing provider image-count limits locally. OpenAI
|
||||
only forward refs that belong to their own provider (OpenAI, xAI, and Gemini images accept them). No shipped route
|
||||
returns a ref yet, and `asset.bytes()` / `materialize()` on a ref fail by design. Raw strings are not accepted as
|
||||
image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
|
||||
non-empty array selects the provider's edit behavior (see the table above for routes that limit the count). OpenAI
|
||||
uses multipart for byte/data-URL edits and its JSON reference body for URL or file-ID edits. The common `mask`
|
||||
field selects inpainting; routes that cannot honor it fail with `UnsupportedOperation`:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
images: [Media.bytes(sourceBytes, "image/png")],
|
||||
mask: Media.bytes(maskBytes, "image/png"),
|
||||
})
|
||||
const inpainted = Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
images: [Media.bytes(sourceBytes, "image/png")],
|
||||
mask: Media.bytes(maskBytes, "image/png"),
|
||||
})
|
||||
```
|
||||
|
||||
On multipart requests, `http.body` can override option fields but not structural `model`, `prompt`, `image[]`,
|
||||
@@ -508,31 +541,31 @@ not accept image inputs. These cases fail with a typed `AIError` before network
|
||||
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
providerOptions: { quality: "medium" },
|
||||
http,
|
||||
})
|
||||
const medium = Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
providerOptions: { quality: "medium" },
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
xAI image models use the same request API with xAI-native controls:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: XAI.configure({ apiKey })("any-model-id"),
|
||||
prompt,
|
||||
n: 2,
|
||||
aspectRatio: "16:9",
|
||||
providerOptions: {
|
||||
resolution: "1k",
|
||||
responseFormat: "b64_json",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
import { XAI } from "@opencode/ai/providers"
|
||||
|
||||
const xai = Image.generate({
|
||||
model: XAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
n: 2,
|
||||
aspectRatio: "16:9",
|
||||
providerOptions: {
|
||||
resolution: "1k",
|
||||
responseFormat: "b64_json",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
Google's current Gemini image models use the same direct API:
|
||||
@@ -542,7 +575,7 @@ import { Google } from "@opencode/ai/providers"
|
||||
|
||||
const googleProgram = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: Google.configure({ apiKey })("any-model-id"),
|
||||
model: Google.configure({ apiKey }).image("any-model-id"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
aspectRatio: "16:9",
|
||||
seed: 42,
|
||||
@@ -567,17 +600,18 @@ their mapped aliases, and `http.body` is the final deep overlay. The selected mo
|
||||
Z.ai image models infer open Z.ai-native options from the selected model:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey })("any-model-id"),
|
||||
prompt,
|
||||
providerOptions: {
|
||||
quality: "hd",
|
||||
userID: "user-123",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
import { ZAI } from "@opencode/ai/providers"
|
||||
|
||||
const zai = Image.generate({
|
||||
model: ZAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
providerOptions: {
|
||||
quality: "hd",
|
||||
userID: "user-123",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
|
||||
@@ -591,12 +625,14 @@ and emits `image-partial` events before each final `image`; `Image.generate` kee
|
||||
`dall-e-*` models do not stream and fail typed:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.stream({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A lighthouse at dusk",
|
||||
providerOptions: { partialImages: 2 },
|
||||
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
|
||||
import { Stream } from "effect"
|
||||
import { ImageEvent } from "@opencode/ai"
|
||||
|
||||
const previews = Image.stream({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A lighthouse at dusk",
|
||||
providerOptions: { partialImages: 2 },
|
||||
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
|
||||
```
|
||||
|
||||
The provider may send fewer previews than requested when the final image is ready first.
|
||||
@@ -612,13 +648,20 @@ import { BlackForestLabs, Stability } from "@opencode/ai/providers"
|
||||
|
||||
const bfl = BlackForestLabs.configure({ apiKey: process.env.BFL_API_KEY })
|
||||
|
||||
const generation = yield * Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
|
||||
persist(generation.token)
|
||||
const submit = Effect.gen(function* () {
|
||||
const generation = yield* Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
|
||||
persist({ provider: "black-forest-labs", modelID: "flux-2-pro", token: generation.token })
|
||||
})
|
||||
|
||||
const resumed = yield * Image.resume(bfl.image("flux-2-pro"), loadToken())
|
||||
const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
|
||||
const finish = Effect.gen(function* () {
|
||||
const saved = load()
|
||||
const resumed = yield* Image.resume(bfl.image(saved.modelID), saved.token)
|
||||
return yield* resumed.await({ poll: { interval: "2 seconds" } })
|
||||
})
|
||||
```
|
||||
|
||||
The token carries no route identity, so persist the provider and model ID alongside it: `resume` needs the model.
|
||||
|
||||
- **Black Forest Labs** — results are downloaded before returning, because `result.sample` expires in 10 minutes.
|
||||
- **Replicate** — inputs are model-defined, so only `prompt` lowers: sizing, count, seed, format, and files go in
|
||||
`providerOptions` under the model's names, with files as `Media.Asset` (data URLs up to 256 KB, larger by URL).
|
||||
@@ -628,12 +671,13 @@ const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
|
||||
|
||||
```ts
|
||||
const stability = Stability.configure({ apiKey: process.env.STABILITY_API_KEY })
|
||||
const upscaled =
|
||||
yield *
|
||||
Image.generate(
|
||||
{ model: stability.upscale(), prompt: "A lighthouse", images: [yield * Media.file("./small.png")] },
|
||||
const upscaled = Effect.gen(function* () {
|
||||
const small = yield* Media.file("./small.png")
|
||||
return yield* Image.generate(
|
||||
{ model: stability.upscale(), prompt: "A lighthouse", images: [small] },
|
||||
{ poll: { interval: "5 seconds" } },
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Imagen is not available: Google shut it down on the Gemini API, and Vertex discontinued the Imagen 4 models on
|
||||
@@ -666,8 +710,8 @@ Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`
|
||||
under `providerOptions`, inferred from the selected model.
|
||||
|
||||
```ts
|
||||
import { Video, VideoClient } from "@opencode/ai"
|
||||
import { Google } from "@opencode/ai/providers"
|
||||
import { Video } from "@opencode/ai"
|
||||
import { Google, Runway } from "@opencode/ai/providers"
|
||||
|
||||
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY })
|
||||
|
||||
@@ -696,6 +740,7 @@ const controlled = Effect.gen(function* () {
|
||||
generation.id // provider operation / task / request id
|
||||
generation.status // "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
|
||||
generation.token // route-owned JSON: `{ operation }`, `{ requestID }`, `{ taskID }`, or fal's follow-up URLs
|
||||
// The token carries no route identity: persist the provider and model ID alongside it, since `resume` needs the model.
|
||||
const saved = JSON.stringify(generation.token)
|
||||
|
||||
const resumed = yield* Video.resume(google.video("veo-3.1-generate-preview"), JSON.parse(saved))
|
||||
@@ -706,8 +751,8 @@ const controlled = Effect.gen(function* () {
|
||||
const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"), prompt }, { poll })
|
||||
```
|
||||
|
||||
`VideoClient.layer` needs `RequestExecutor.Service`, and status polls, result fetches, cancels, and asset downloads
|
||||
all run through the same executor with the route's auth. `Generation.await` and `Generation.events` fail with a
|
||||
Status polls, result fetches, cancels, and asset downloads all run through the same request executor with the route's
|
||||
auth. `Generation.await` and `Generation.events` fail with a
|
||||
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Failed,
|
||||
cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
|
||||
outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
|
||||
@@ -726,15 +771,18 @@ Provider notes:
|
||||
- **Runway** expects pixel ratios in `aspectRatio` for most models (`"1280:720"`), pins `X-Runway-Version`, reports
|
||||
`usage: { type: "credits" }`, and its output URLs expire after 24–48 hours.
|
||||
|
||||
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `refresh`,
|
||||
`cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and `ai.video.stream` mirror the Effect
|
||||
API.
|
||||
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `events`,
|
||||
`result`, `refresh`, `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and
|
||||
`ai.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was
|
||||
created; `refresh()` resolves to a new handle.
|
||||
|
||||
```ts
|
||||
import { ai } from "@opencode/ai/promise"
|
||||
|
||||
const generation = await ai.video.start({ model, prompt })
|
||||
const video = await generation.await({ poll: { interval: 10_000 }, signal })
|
||||
for await (const event of generation.events({ poll: { interval: 10_000 } })) console.log(event.type)
|
||||
const video = await generation.result({ signal })
|
||||
await ai.write(video.video, "./kite.mp4")
|
||||
```
|
||||
|
||||
## Speech generation
|
||||
@@ -786,7 +834,6 @@ ElevenLabs and Cartesia. `{ id }` selects an OpenAI custom voice (`{ id: "voice_
|
||||
plain string elsewhere. There is no cross-provider voice catalog. `format` is the container-level word (`mp3`, `wav`,
|
||||
`pcm`, `opus`, `aac`, `flac`); sample rates and bitrates live under `providerOptions`, and a value the route cannot
|
||||
produce fails as `UnsupportedOperation`. Streams buffer every chunk so `finish` can carry the whole clip.
|
||||
`SpeechClient.layer` needs `RequestExecutor.Service`.
|
||||
|
||||
Provider notes:
|
||||
|
||||
@@ -814,7 +861,7 @@ The promise client mirrors the Effect API; `ai.speech.stream` is an `AsyncIterab
|
||||
import { ai } from "@opencode/ai/promise"
|
||||
|
||||
const response = await ai.speech.generate({ model, text: "Hello from OpenCode.", voice: "coral" })
|
||||
await Bun.write("hello.mp3", await ai.run(response.audio.bytes()))
|
||||
await ai.write(response.audio, "hello.mp3")
|
||||
|
||||
for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode.", voice: "coral" })) {
|
||||
if (event.type === "audio-delta") player.write(event.chunk)
|
||||
@@ -831,6 +878,7 @@ facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" |
|
||||
natively or fail with a typed `AIError` before any network call; a route may return more than asked.
|
||||
|
||||
```ts
|
||||
import { Console, Effect, Stream } from "effect"
|
||||
import { Media, Transcription, TranscriptionEvent } from "@opencode/ai"
|
||||
import { AssemblyAI, Deepgram, OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
@@ -857,7 +905,7 @@ const program = Effect.gen(function* () {
|
||||
Stream.runDrain,
|
||||
)
|
||||
|
||||
// Queued: persist the token, resume from another process, and await.
|
||||
// Queued: persist the token with the provider and model ID (the token alone cannot pick the model), resume, and await.
|
||||
const model = AssemblyAI.configure({ apiKey }).transcription("universal-3-5-pro")
|
||||
const generation = yield* Transcription.start({ model, audio })
|
||||
const resumed = yield* Transcription.resume(model, JSON.parse(JSON.stringify(generation.token)))
|
||||
@@ -866,7 +914,7 @@ const program = Effect.gen(function* () {
|
||||
```
|
||||
|
||||
Inline routes emit only `finish` from `stream` (no faked deltas); queued routes emit `generation-queued` /
|
||||
`generation-progress` before it. `TranscriptionClient.layer` needs `RequestExecutor.Service`.
|
||||
`generation-progress` before it.
|
||||
|
||||
Provider notes:
|
||||
|
||||
@@ -878,6 +926,7 @@ Provider notes:
|
||||
The promise client mirrors the Effect API:
|
||||
|
||||
```ts
|
||||
const audio = await ai.file("./call.mp3")
|
||||
const text = (await ai.transcription.generate({ model, audio })).text
|
||||
for await (const event of ai.transcription.stream({ model, audio })) if (event.type === "text-delta") write(event.delta)
|
||||
const generation = await ai.transcription.start({ model: assemblyai, audio })
|
||||
@@ -897,7 +946,8 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
|
||||
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
|
||||
- **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
|
||||
- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
|
||||
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs.
|
||||
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
|
||||
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -960,11 +1010,13 @@ This is different from prompt caching, server-side history storage, or truncatio
|
||||
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
|
||||
|
||||
```ts
|
||||
const result = yield * LLMClient.compact(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: result.replacement,
|
||||
const compacted = Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: result.replacement,
|
||||
})
|
||||
return yield* LLMClient.generate(next)
|
||||
})
|
||||
const response = yield * LLMClient.generate(next)
|
||||
```
|
||||
|
||||
`replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly.
|
||||
@@ -980,16 +1032,16 @@ The input must still fit the model's context window. Explicit compaction is not
|
||||
OpenAI Responses also exposes a separate, explicitly selected mechanism:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
LLMClient.compact(request, {
|
||||
const checkpoint = Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket, // Optional: without it, the request uses HTTP/SSE.
|
||||
})
|
||||
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
})
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request, with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Request metadata, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
@@ -1003,9 +1055,11 @@ The supplied WebSocket executor can reuse a compatible append baseline for the c
|
||||
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
|
||||
|
||||
```ts
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
const narrowed = Effect.gen(function* () {
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield* LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
|
||||
@@ -1014,9 +1068,10 @@ This capability describes protocol implementation, **not universal availability
|
||||
|
||||
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
|
||||
|
||||
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
|
||||
Enable OpenAI compaction with typed provider options:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMClient, LLMRequest, Message } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
@@ -1027,9 +1082,11 @@ const request = LLM.request({
|
||||
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
|
||||
},
|
||||
})
|
||||
const response = yield * LLMClient.generate(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: [...request.messages, response.message, Message.user("Continue")],
|
||||
const continued = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
return LLMRequest.update(request, {
|
||||
messages: [...request.messages, response.message, Message.user("Continue")],
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
@@ -1257,7 +1314,7 @@ Compose a route with `Route.make({ protocol, endpoint, auth, framing, ... })`. T
|
||||
|
||||
## Effect
|
||||
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `AIClient.layer` (or `AIClient.layerWith(executor)`) for every modality, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Media generation in `@opencode/ai` — public API direction
|
||||
|
||||
Status: phases 1–3 implemented (Speech and Transcription); phases 4–5 proposal.
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -54,7 +54,7 @@ Speech.request({ model: openai.speech("gpt-4o-mini-tts"), text })
|
||||
Transcription.request({ model: openai.transcription("gpt-4o-transcribe"), audio })
|
||||
```
|
||||
|
||||
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). Provider package entrypoints keep `model(modelID, settings)` per modality-specific path, e.g. `@opencode/ai/providers/openai/responses`.
|
||||
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). The facade selector (`openai.image(id)`) is the public path for media models. Modality-specific package entrypoints (`model(modelID, settings)` beside today's LLM paths such as `@opencode/ai/providers/openai/responses`) are deferred until Core has a modality-aware model resolver; Core's resolver accepts only `LanguageModel` today.
|
||||
|
||||
### `Media` — the asset type
|
||||
|
||||
@@ -86,10 +86,18 @@ class Media.Asset {
|
||||
|
||||
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
|
||||
Media.url(url, options?) Media.ref(provider, id)
|
||||
Media.file(path) // Bun/Node: reads + sniffs; Effect FileSystem variant for layers
|
||||
Media.write(asset, path) // convenience, uses FileSystem
|
||||
Media.file(path) // Effect<Asset, AIError, FileSystem>: reads + sniffs
|
||||
Media.write(asset, path) // Effect<void, AIError, FileSystem | RequestExecutor.Service>
|
||||
```
|
||||
|
||||
`Media.file` and `Media.write` stay Effect-only: bring your platform's `FileSystem` layer. The Promise client owns the
|
||||
runtime path: `ai.file(path)` and `ai.write(asset, path)` read and write through `node:fs/promises` (loaded on first
|
||||
use) with the same media-type sniffing and `InvalidRequest` failures, and `ai.bytes`, `ai.base64`, and
|
||||
`ai.materialize` run the asset methods in its runtime.
|
||||
|
||||
A `ref` source is accepted as input only by routes whose provider issues file handles. No shipped route produces one
|
||||
yet, so `bytes()` and `materialize()` on a ref fail by design until a producer exists.
|
||||
|
||||
Raw-PCM outputs (Gemini TTS, Cartesia raw, Deepgram WS) carry `info.encoding/sampleRate/channels` because there is no container header.
|
||||
|
||||
### Modality namespaces
|
||||
@@ -104,28 +112,32 @@ import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
|
||||
#### Image
|
||||
|
||||
```ts
|
||||
const request = Image.request({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
images: [Media.file("./ref.png")], // references / edit sources
|
||||
mask: Media.file("./mask.png"),
|
||||
n: 2,
|
||||
size: "1536x1024", // or aspectRatio: "3:2"
|
||||
seed: 7,
|
||||
format: "webp",
|
||||
providerOptions: { quality: "high", background: "transparent" }, // typed per model
|
||||
Effect.gen(function* () {
|
||||
const request = Image.request({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
images: [yield* Media.file("./ref.png")], // references / edit sources
|
||||
mask: yield* Media.file("./mask.png"),
|
||||
n: 2,
|
||||
size: "1536x1024", // OpenAI sizes by pixels; Gemini/xAI take aspectRatio instead
|
||||
format: "webp",
|
||||
providerOptions: { quality: "high", background: "transparent" }, // typed per model
|
||||
})
|
||||
|
||||
const response = yield* Image.generate(request) // ImageResponse
|
||||
response.image // Media.Asset (first)
|
||||
response.images // Media.Asset[]
|
||||
response.usage // Usage union (see below)
|
||||
response.notices // moderation / partial-result notices
|
||||
|
||||
Image.stream(request) // Stream<ImageEvent>
|
||||
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
|
||||
})
|
||||
|
||||
const response = yield* Image.generate(request) // ImageResponse
|
||||
response.image // Media.Asset (first)
|
||||
response.images // Media.Asset[]
|
||||
response.usage // Usage union (see below)
|
||||
response.notices // moderation / partial-result notices
|
||||
|
||||
yield* Image.stream(request) // Stream<ImageEvent>
|
||||
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
|
||||
```
|
||||
|
||||
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the README's Image
|
||||
portability matrix.
|
||||
|
||||
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
|
||||
|
||||
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
|
||||
@@ -135,40 +147,43 @@ Editing is not a separate function; `images`/`mask` on the request select the ed
|
||||
Shipped in phase 2 (`src/video.ts`, `src/video-client.ts`, protocols `google-video`, `xai-video`, `fal-video`, `runway-video`).
|
||||
|
||||
```ts
|
||||
const request = Video.request({
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
frames: { first: Media.file("./start.png"), last: Media.file("./end.png") },
|
||||
references: [Media.file("./style.png")],
|
||||
video: Media.bytes(previous, "video/mp4"), // edit / extend source
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
n: 1,
|
||||
seed: 7,
|
||||
negativePrompt: "text, watermark", // common, not provider-native
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
Effect.gen(function* () {
|
||||
const request = Video.request({
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
frames: { first: yield* Media.file("./start.png"), last: yield* Media.file("./end.png") },
|
||||
references: [yield* Media.file("./style.png")],
|
||||
video: Media.bytes(previous, "video/mp4"), // edit / extend source
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
n: 1,
|
||||
seed: 7,
|
||||
negativePrompt: "text, watermark", // common, not provider-native
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
})
|
||||
|
||||
// Simple: wait for it.
|
||||
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
|
||||
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
// Explicit generation control.
|
||||
const generation = yield* Video.start(request) // Generation<VideoResponse>
|
||||
generation.id; generation.status; generation.progress; generation.position; generation.token
|
||||
yield* generation.await({ poll }) // VideoResponse
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
|
||||
|
||||
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
|
||||
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
|
||||
const resumed = yield* Video.resume(model, JSON.parse(saved))
|
||||
|
||||
// Progress as a stream.
|
||||
Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
|
||||
})
|
||||
|
||||
// Simple: wait for it.
|
||||
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
|
||||
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
// Explicit generation control.
|
||||
const generation = yield* Video.start(request) // Generation<VideoResponse>
|
||||
generation.id; generation.status; generation.progress; generation.position; generation.token
|
||||
yield* generation.await({ poll }) // VideoResponse
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
|
||||
|
||||
// Resume from another process. The token is validated against the route's codec and refreshed once.
|
||||
const resumed = yield* Video.resume(model, JSON.parse(saved))
|
||||
|
||||
// Progress as a stream.
|
||||
yield* Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
|
||||
```
|
||||
|
||||
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
|
||||
@@ -330,12 +345,12 @@ class Generation<Response> {
|
||||
readonly expiresAt?: number
|
||||
refresh(): Effect<Generation<Response>, AIError>
|
||||
result(): Effect<Response, AIError>
|
||||
await(options?: AwaitOptions): Effect<Response, AIError>
|
||||
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
|
||||
cancel(): Effect<void, AIError>
|
||||
events(options?: AwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
|
||||
events(options?: GenerationAwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
|
||||
}
|
||||
|
||||
AwaitOptions = { poll?: Poll }
|
||||
GenerationAwaitOptions = { poll?: Poll }
|
||||
Poll = { interval?: Duration; timeout?: Duration; schedule?: Schedule } // route may override from provider hints (`openai-poll-after-ms`)
|
||||
```
|
||||
|
||||
@@ -365,15 +380,17 @@ const ai = AI.make() // ManagedRuntime over Reque
|
||||
// AI.make({ layer }) to inject a custom executor / recorder / middleware
|
||||
|
||||
const image = await ai.image.generate({ model, prompt })
|
||||
await image.image.bytes()
|
||||
await ai.bytes(image.image) // also ai.base64, ai.materialize, ai.write(asset, path)
|
||||
const reference = await ai.file("./ref.png")
|
||||
|
||||
for await (const event of ai.speech.stream({ model, text, voice })) { … }
|
||||
|
||||
const generation = await ai.video.start({ model, prompt })
|
||||
const generation = await ai.video.start({ model, prompt }) // snapshot handle; refresh() returns a new one
|
||||
for await (const event of generation.events({ poll: { interval: 10_000 } })) { … }
|
||||
const video = await generation.await({ poll: { interval: 10_000 }, signal })
|
||||
const resumed = ai.video.resume(model, JSON.parse(saved))
|
||||
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
|
||||
|
||||
const text = await ai.llm.generate({ model, prompt }) // closes today's gap: LLM has no promise API either
|
||||
const text = await ai.llm.generate({ model, prompt })
|
||||
for await (const event of ai.llm.stream(request)) { … }
|
||||
|
||||
await ai.dispose()
|
||||
@@ -401,9 +418,9 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
|
||||
| `Runway` | | | ✓ | | | |
|
||||
| `Luma`, `Kling`, `MiniMax` | | per provider | | | | |
|
||||
|
||||
New facades follow the existing one-file-per-provider rule. Package entrypoints are modality-specific, such as `@opencode/ai/providers/openai/images`, and return the concrete model.
|
||||
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
|
||||
|
||||
`ImageModel<Options>` already gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, `TranscriptionModel` follow the same generic. A shared `MediaModel` union is what `Generation` and the promise client key on.
|
||||
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
|
||||
|
||||
### Routes and protocols
|
||||
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
import { Config, Effect, Formatter, Schema, Stream } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import {
|
||||
Image,
|
||||
ImageClient,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMRequest,
|
||||
Media,
|
||||
Message,
|
||||
ProviderID,
|
||||
Tool,
|
||||
ToolRuntime,
|
||||
} from "@opencode/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode/ai/route"
|
||||
import { AIClient, Image, LLM, LLMRequest, Media, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol } from "@opencode/ai/route"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
/**
|
||||
@@ -243,12 +232,10 @@ const generateImage = Effect.gen(function* () {
|
||||
yield* Media.write(response.image, "tutorial-image.jpg").pipe(Effect.provide(NodeFileSystem.layer))
|
||||
})
|
||||
|
||||
// Provide the LLM runtime and the HTTP request executor once. Keep one path
|
||||
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// Provide every modality client and the HTTP request executor once with
|
||||
// `AIClient.layer` (`AIClient.layerWith(executor)` swaps the executor). Keep one
|
||||
// path enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// tool-loop behavior without spending tokens on every example.
|
||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
const imageClientLayer = ImageClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
// yield* generateOnce
|
||||
@@ -257,6 +244,6 @@ const program = Effect.gen(function* () {
|
||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||
// yield* generateImage
|
||||
yield* streamWithTools
|
||||
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer, imageClientLayer)))
|
||||
}).pipe(Effect.provide(AIClient.layer))
|
||||
|
||||
Effect.runPromise(program)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Layer } from "effect"
|
||||
import { ImageClient } from "./image-client.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { SpeechClient } from "./speech-client.js"
|
||||
import { TranscriptionClient } from "./transcription-client.js"
|
||||
import { VideoClient } from "./video-client.js"
|
||||
|
||||
/** Every modality client over `executor`, which stays in the output so `asset.bytes()` and `Media.write` resolve. */
|
||||
export const layerWith = <E, R>(executor: Layer.Layer<RequestExecutor.Service, E, R>) =>
|
||||
Layer.mergeAll(
|
||||
LLMClient.layer,
|
||||
ImageClient.layer,
|
||||
VideoClient.layer,
|
||||
SpeechClient.layer,
|
||||
TranscriptionClient.layer,
|
||||
).pipe(Layer.provideMerge(executor))
|
||||
|
||||
/** Every modality client plus the executor over `RequestExecutor.fetchLayer`: the one layer most programs need. */
|
||||
export const layer = layerWith(RequestExecutor.fetchLayer)
|
||||
|
||||
export type Services = Layer.Success<typeof layer>
|
||||
|
||||
export * as AIClient from "./ai-client.js"
|
||||
@@ -86,7 +86,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const fetchLayer = layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
export const fetchLayer = layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
|
||||
|
||||
export const EvaluationClient = {
|
||||
Service,
|
||||
|
||||
@@ -30,7 +30,9 @@ export interface Interface {
|
||||
) => Effect.Effect<Generation<ImageResponse>, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||
export class ImageClientService extends Context.Service<ImageClientService, Interface>()("@opencode/ImageClient") {}
|
||||
export const Service = ImageClientService
|
||||
export type Service = ImageClientService
|
||||
|
||||
export const generate = <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AIClient } from "./ai-client.js"
|
||||
export { LLMClient } from "./route/client.js"
|
||||
export { ImageClient } from "./image-client.js"
|
||||
export { Auth } from "./route/auth.js"
|
||||
@@ -8,7 +9,7 @@ export type {
|
||||
RouteLanguageModelInput,
|
||||
RouteRoutedLanguageModelInput,
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
LLMClientService,
|
||||
} from "./route/client.js"
|
||||
export * from "./schema/index.js"
|
||||
export {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProviderID } from "./schema/ids.js"
|
||||
import { AIError, HttpContext, InvalidProviderOutputError, InvalidRequestError } from "./schema/errors.js"
|
||||
import { ProviderMetadata } from "./schema/options.js"
|
||||
import { Service } from "./route/executor-service.js"
|
||||
import { detectMediaType, extensionMediaType } from "./utils/media-type.js"
|
||||
import { detectMediaType, fileMediaType } from "./utils/media-type.js"
|
||||
|
||||
export { detectMediaType } from "./utils/media-type.js"
|
||||
|
||||
@@ -309,7 +309,7 @@ export const file = (path: string, options?: AssetOptions): Effect.Effect<Asset,
|
||||
const data = yield* fs
|
||||
.readFile(path)
|
||||
.pipe(Effect.mapError((cause) => invalid(`Failed to read media file ${path}`, cause)))
|
||||
return bytes(data, detectMediaType(data) ?? extensionMediaType(path), options)
|
||||
return bytes(data, fileMediaType(data, path), options)
|
||||
})
|
||||
|
||||
/** Materialize an asset and write its bytes through `FileSystem`. */
|
||||
|
||||
+64
-30
@@ -1,14 +1,14 @@
|
||||
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
|
||||
import type { AwaitOptions, Generation, Snapshot } from "./generation.js"
|
||||
import { AIClient } from "./ai-client.js"
|
||||
import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js"
|
||||
import { Image, ImageModel, ImageRequest, type ImageOptions, type ImageRequestInput } from "./image.js"
|
||||
import { ImageClient } from "./image-client.js"
|
||||
import { LLM } from "./index.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import { Media } from "./media.js"
|
||||
import { tryRequest } from "./media-model.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { LanguageModel, LLMRequest } from "./schema/index.js"
|
||||
import { AIError, InvalidRequestError, LanguageModel, LLMRequest } from "./schema/index.js"
|
||||
import type { RequestInput } from "./llm.js"
|
||||
import { Speech, SpeechModel, SpeechRequest, type SpeechRequestInput } from "./speech.js"
|
||||
import { SpeechClient } from "./speech-client.js"
|
||||
import {
|
||||
Transcription,
|
||||
TranscriptionModel,
|
||||
@@ -16,17 +16,16 @@ import {
|
||||
type TranscriptionOptions,
|
||||
type TranscriptionRequestInput,
|
||||
} from "./transcription.js"
|
||||
import { TranscriptionClient } from "./transcription-client.js"
|
||||
import { fileMediaType } from "./utils/media-type.js"
|
||||
import { Video, VideoModel, VideoRequest, type VideoOptions, type VideoRequestInput } from "./video.js"
|
||||
import { VideoClient } from "./video-client.js"
|
||||
|
||||
/**
|
||||
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM, image, video, speech,
|
||||
* and transcription clients over a request executor; every method runs the corresponding Effect API and rethrows
|
||||
* `AIError` unchanged.
|
||||
* `AIError` unchanged. `file` and `write` load `node:fs/promises` on first use, so importing this module does not.
|
||||
*/
|
||||
export interface Options {
|
||||
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or middleware here. */
|
||||
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or `RequestExecutor.middleware(fn)` here. */
|
||||
readonly layer?: Layer.Layer<RequestExecutor.Service>
|
||||
}
|
||||
|
||||
@@ -34,19 +33,20 @@ export interface RunOptions {
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type Services =
|
||||
| Layer.Success<typeof LLMClient.layer>
|
||||
| Layer.Success<typeof ImageClient.layer>
|
||||
| Layer.Success<typeof VideoClient.layer>
|
||||
| Layer.Success<typeof SpeechClient.layer>
|
||||
| Layer.Success<typeof TranscriptionClient.layer>
|
||||
| RequestExecutor.Service
|
||||
export type Services = AIClient.Services
|
||||
|
||||
/** Promise view of a `Generation`: its snapshot plus `await`, `refresh`, and `cancel` returning promises. */
|
||||
/**
|
||||
* Promise view of a `Generation`. Its fields are a snapshot taken when the handle was created; `refresh()` resolves to a
|
||||
* new handle rather than updating this one.
|
||||
*/
|
||||
export type GenerationHandle<Response> = Snapshot & {
|
||||
/** Serializable JSON; pass it back to `resume` from another process. */
|
||||
readonly token: unknown
|
||||
readonly await: (options?: AwaitOptions & RunOptions) => Promise<Response>
|
||||
/** Status observations until the first terminal one, polling like `await`; abort ends iteration without throwing. */
|
||||
readonly events: (options?: AwaitOptions & RunOptions) => AsyncIterable<Event>
|
||||
/** The result without polling; fails when the generation has not completed. */
|
||||
readonly result: (options?: RunOptions) => Promise<Response>
|
||||
readonly refresh: (options?: RunOptions) => Promise<GenerationHandle<Response>>
|
||||
readonly cancel: (options?: RunOptions) => Promise<void>
|
||||
}
|
||||
@@ -65,17 +65,9 @@ const abortEffect = (signal: AbortSignal | undefined) =>
|
||||
})
|
||||
|
||||
export const make = (options: Options = {}) => {
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
LLMClient.layer,
|
||||
ImageClient.layer,
|
||||
VideoClient.layer,
|
||||
SpeechClient.layer,
|
||||
TranscriptionClient.layer,
|
||||
).pipe(Layer.provideMerge(options.layer ?? RequestExecutor.fetchLayer)),
|
||||
)
|
||||
const runtime = ManagedRuntime.make(AIClient.layerWith(options.layer ?? RequestExecutor.fetchLayer))
|
||||
|
||||
/** Run any package Effect (for example `asset.bytes()`) inside this runtime. */
|
||||
/** Run any package Effect (for example `LLMClient.compact(...)`) inside this runtime. */
|
||||
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
|
||||
runtime.runPromise(effect, { signal: options?.signal })
|
||||
|
||||
@@ -95,12 +87,15 @@ export const make = (options: Options = {}) => {
|
||||
...generation.snapshot,
|
||||
token: generation.token,
|
||||
await: (options) => run(generation.await({ poll: options?.poll }), options),
|
||||
events: (options) => iterate(generation.events({ poll: options?.poll }), options),
|
||||
result: (options) => run(generation.result(), options),
|
||||
refresh: (options) => run(generation.refresh(), options).then(handle),
|
||||
cancel: (options) => run(generation.cancel(), options),
|
||||
})
|
||||
|
||||
// The typed `generate`/`stream` overloads take a concrete input or a request, not the union; normalize once here.
|
||||
const llmRequest = (input: RequestInput | LLMRequest) => (input instanceof LLMRequest ? input : LLM.request(input))
|
||||
const llmRequest = (input: RequestInput | LLMRequest) =>
|
||||
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => LLM.request(input))
|
||||
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
|
||||
input instanceof ImageRequest ? input : Image.request(input)
|
||||
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
|
||||
@@ -112,12 +107,48 @@ export const make = (options: Options = {}) => {
|
||||
|
||||
return {
|
||||
run,
|
||||
/** Decoded asset bytes, downloading `url` sources through the executor. */
|
||||
bytes: (asset: Media.Asset, options?: RunOptions) => run(asset.bytes(), options),
|
||||
base64: (asset: Media.Asset, options?: RunOptions) => run(asset.base64(), options),
|
||||
/** Pull a `url` asset into owned bytes before the provider URL expires. */
|
||||
materialize: (asset: Media.Asset, options?: RunOptions) => run(asset.materialize(), options),
|
||||
/** Read a file into an asset like `Media.file`: sniffed media type, then the extension's. */
|
||||
file: async (path: string, options?: Media.AssetOptions & RunOptions) => {
|
||||
const { readFile } = await import("node:fs/promises")
|
||||
return run(
|
||||
Effect.tryPromise({
|
||||
try: (signal) => readFile(path, { signal }),
|
||||
catch: (cause) => fileError(`Failed to read media file ${path}`, cause),
|
||||
}).pipe(
|
||||
Effect.map((buffer) => {
|
||||
const data = new Uint8Array(buffer)
|
||||
return Media.bytes(data, fileMediaType(data, path), options)
|
||||
}),
|
||||
),
|
||||
options,
|
||||
)
|
||||
},
|
||||
/** Write an asset's bytes like `Media.write`, downloading `url` sources through the executor. */
|
||||
write: async (asset: Media.Asset, path: string, options?: RunOptions) => {
|
||||
const { writeFile } = await import("node:fs/promises")
|
||||
return run(
|
||||
asset.bytes().pipe(
|
||||
Effect.flatMap((data) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => writeFile(path, data, { signal }),
|
||||
catch: (cause) => fileError(`Failed to write media file ${path}`, cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
options,
|
||||
)
|
||||
},
|
||||
llm: {
|
||||
request: LLM.request,
|
||||
generate: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
|
||||
run(LLM.generate(llmRequest(input)), options),
|
||||
run(Effect.flatMap(llmRequest(input), LLM.generate), options),
|
||||
stream: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
|
||||
iterate(LLM.stream(llmRequest(input)), options),
|
||||
iterate(Stream.unwrap(Effect.map(llmRequest(input), LLM.stream)), options),
|
||||
},
|
||||
image: {
|
||||
request: Image.request,
|
||||
@@ -186,6 +217,9 @@ export const make = (options: Options = {}) => {
|
||||
|
||||
export type Client = ReturnType<typeof make>
|
||||
|
||||
const fileError = (message: string, cause: unknown) =>
|
||||
new AIError({ reason: new InvalidRequestError({ message, cause }) })
|
||||
|
||||
/** Default client over `RequestExecutor.fetchLayer` for scripts; the runtime builds its layer on first use. */
|
||||
export const ai = make()
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ const PCM_SAMPLE_RATE = 24000
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OpenAISpeechOptions = Record<string, unknown>
|
||||
/** `voice`, `instructions`, `speed`, and `format` are common request fields; other native body fields pass through. */
|
||||
export type OpenAISpeechOptions = {
|
||||
/** Defaults to `"sse"` in `stream` mode on models that support it; the merged value selects the response framing. */
|
||||
readonly stream_format?: "sse" | "audio"
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = SpeechRequestFor<OpenAISpeechOptions>
|
||||
|
||||
|
||||
@@ -211,7 +211,8 @@ export const mediaReference = (
|
||||
if (provider !== undefined && asset.source.type === "ref" && asset.source.provider === provider)
|
||||
return Effect.succeed({ type: "ref", value: asset.source.id })
|
||||
const accepted = provider === undefined ? "" : `, and ${provider} references`
|
||||
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}`))
|
||||
const got = asset.source.type === "ref" ? `; got ${asset.source.provider}:${asset.source.id}` : ""
|
||||
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}${got}`))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Config, Effect, Redacted } from "effect"
|
||||
import { Config, Effect, Option, Redacted } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AuthenticationError, InvalidRequestError, AIError, type HttpOptions } from "../schema/index.js"
|
||||
import { AuthenticationError, AIError, type HttpOptions } from "../schema/index.js"
|
||||
|
||||
export class MissingCredentialError extends Error {
|
||||
readonly _tag = "MissingCredentialError"
|
||||
|
||||
constructor(readonly source: string) {
|
||||
super(`Missing auth credential: ${source}`)
|
||||
constructor(
|
||||
readonly source: string,
|
||||
message = `Missing auth credential: ${source}`,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +92,14 @@ export const optional = (secret: Secret | undefined, source = "optional value")
|
||||
? credential(Effect.fail(new MissingCredentialError(source)))
|
||||
: credentialFromSecret(secret, source)
|
||||
|
||||
export const config = (name: string) => credentialFromSecret(Config.redacted(name), name)
|
||||
export const config = (name: string) =>
|
||||
credential(
|
||||
Effect.gen(function* () {
|
||||
const secret = yield* Config.option(Config.redacted(name))
|
||||
if (Option.isSome(secret) && Redacted.value(secret.value) !== "") return secret.value
|
||||
return yield* Effect.fail(new MissingCredentialError(name, `${name} is not set`))
|
||||
}),
|
||||
)
|
||||
|
||||
export const effect = (load: Effect.Effect<Redacted.Redacted, CredentialError>) => credential(load)
|
||||
|
||||
@@ -145,15 +155,10 @@ export function scheme(name: string, source?: Secret | Credential) {
|
||||
}
|
||||
|
||||
const toAIError = (error: AuthError): AIError => {
|
||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
||||
return new AIError({
|
||||
reason:
|
||||
error instanceof MissingCredentialError
|
||||
? new AuthenticationError({ message: error.message, cause: error })
|
||||
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
|
||||
})
|
||||
}
|
||||
return error
|
||||
if (error instanceof AIError) return error
|
||||
const message =
|
||||
error instanceof MissingCredentialError ? error.message : `Failed to resolve auth config: ${error.message}`
|
||||
return new AIError({ reason: new AuthenticationError({ message, cause: error }) })
|
||||
}
|
||||
|
||||
export const toEffect =
|
||||
|
||||
@@ -261,7 +261,9 @@ const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefine
|
||||
})
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
export class LLMClientService extends Context.Service<LLMClientService, Interface>()("@opencode/LLMClient") {}
|
||||
export const Service = LLMClientService
|
||||
export type Service = LLMClientService
|
||||
|
||||
const resolveRequestOptions = (request: LLMRequest) => {
|
||||
const messages = normalizeToolHistory(request.messages)
|
||||
|
||||
@@ -19,4 +19,8 @@ export type HttpMiddleware = (
|
||||
handler: HttpHandler,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
||||
export class RequestExecutorService extends Context.Service<RequestExecutorService, Interface>()(
|
||||
"@opencode/AI/RequestExecutor",
|
||||
) {}
|
||||
export const Service = RequestExecutorService
|
||||
export type Service = RequestExecutorService
|
||||
|
||||
@@ -255,4 +255,20 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
|
||||
export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
/** Run `fn` on every request: it sees the raw response before status classification, inside middleware already on `executor`, and outside per-call middleware. */
|
||||
export const middleware = (fn: HttpMiddleware, executor: Layer.Layer<Service> = fetchLayer): Layer.Layer<Service> =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const inner = yield* Service
|
||||
return Service.of({
|
||||
execute: (request, next) =>
|
||||
inner.execute(
|
||||
request,
|
||||
next === undefined ? fn : (input, handler) => fn(input, (forwarded) => next(forwarded, handler)),
|
||||
),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(executor))
|
||||
|
||||
export * as RequestExecutor from "./executor.js"
|
||||
|
||||
@@ -7,7 +7,7 @@ export type {
|
||||
RouteDefaultsInput,
|
||||
AnyRoute,
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
LLMClientService,
|
||||
StreamOptions,
|
||||
CompactMethod,
|
||||
CompactionOperations,
|
||||
|
||||
@@ -12,7 +12,9 @@ export interface Interface {
|
||||
) => Stream.Stream<SpeechEvent, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SpeechClient") {}
|
||||
export class SpeechClientService extends Context.Service<SpeechClientService, Interface>()("@opencode/SpeechClient") {}
|
||||
export const Service = SpeechClientService
|
||||
export type Service = SpeechClientService
|
||||
|
||||
export const generate = <Options extends SpeechOptions>(
|
||||
request: SpeechRequestFor<Options>,
|
||||
|
||||
@@ -30,7 +30,11 @@ export interface Interface {
|
||||
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TranscriptionClient") {}
|
||||
export class TranscriptionClientService extends Context.Service<TranscriptionClientService, Interface>()(
|
||||
"@opencode/TranscriptionClient",
|
||||
) {}
|
||||
export const Service = TranscriptionClientService
|
||||
export type Service = TranscriptionClientService
|
||||
|
||||
export const generate = <Options extends TranscriptionOptions>(
|
||||
request: TranscriptionRequestFor<Options>,
|
||||
|
||||
@@ -48,9 +48,12 @@ const EXTENSIONS: Readonly<Record<string, string>> = {
|
||||
csv: "text/csv",
|
||||
}
|
||||
|
||||
export const extensionMediaType = (path: string): string | undefined =>
|
||||
const extensionMediaType = (path: string): string | undefined =>
|
||||
EXTENSIONS[path.slice(path.lastIndexOf(".") + 1).toLowerCase()]
|
||||
|
||||
/** Media type of a file's contents: sniffed magic bytes, then the path's extension. */
|
||||
export const fileMediaType = (bytes: Uint8Array, path: string) => detectMediaType(bytes) ?? extensionMediaType(path)
|
||||
|
||||
const EXTENSION_ALIASES: Readonly<Record<string, string>> = {
|
||||
"audio/mp3": "mp3",
|
||||
"audio/m4a": "m4a",
|
||||
|
||||
@@ -29,7 +29,9 @@ export interface Interface {
|
||||
) => Stream.Stream<VideoEvent, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/VideoClient") {}
|
||||
export class VideoClientService extends Context.Service<VideoClientService, Interface>()("@opencode/VideoClient") {}
|
||||
export const Service = VideoClientService
|
||||
export type Service = VideoClientService
|
||||
|
||||
export const start = <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
|
||||
@@ -92,6 +92,15 @@ describe("Auth", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports a missing config credential as Authentication naming the variable", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Auth.toEffect(Auth.config("OPENAI_API_KEY").bearer())(input).pipe(withEnv({}), Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("Authentication")
|
||||
expect(error.message).toContain("OPENAI_API_KEY is not set")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("can intentionally leave auth untouched", () =>
|
||||
Effect.gen(function* () {
|
||||
const headers = yield* Auth.none.apply(input)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
|
||||
import { route } from "../src/protocols/openai-chat.js"
|
||||
import { configure } from "../src/providers/openai.js"
|
||||
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
|
||||
import { dynamicResponse, fixedResponse, handlerLayer, systemError } from "./lib/http.js"
|
||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||
import { sseEvents, sseRaw } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
@@ -195,6 +195,26 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("runs shared middleware outside per-call middleware", () => {
|
||||
const calls: Array<string> = []
|
||||
const record = (name: string) => Effect.sync(() => calls.push(name))
|
||||
const base = RequestExecutor.layer.pipe(
|
||||
Layer.provide(handlerLayer((input) => record("handler").pipe(Effect.as(input.respond("ok"))))),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
yield* executor.execute(request, (input, next) => record("per-call").pipe(Effect.andThen(next(input))))
|
||||
expect(calls).toEqual(["outer", "per-call", "handler"])
|
||||
calls.length = 0
|
||||
yield* executor.execute(request)
|
||||
expect(calls).toEqual(["outer", "handler"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
RequestExecutor.middleware((input, next) => record("outer").pipe(Effect.andThen(next(input))), base),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("classifies context overflow responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import {
|
||||
AIClient,
|
||||
AIError,
|
||||
Generation,
|
||||
Image,
|
||||
ImageClient,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
@@ -11,10 +14,11 @@ import {
|
||||
Speech,
|
||||
SpeechClient,
|
||||
SpeechEvent,
|
||||
TranscriptionClient,
|
||||
Video,
|
||||
VideoClient,
|
||||
} from "@opencode/ai"
|
||||
import { Route, Protocol, WebSocketTransport } from "@opencode/ai/route"
|
||||
import { Route, Protocol, RequestExecutor, WebSocketTransport } from "@opencode/ai/route"
|
||||
import { Provider as ProviderSubpath } from "@opencode/ai/provider"
|
||||
import {
|
||||
AssemblyAI,
|
||||
@@ -76,6 +80,28 @@ describe("public exports", () => {
|
||||
expect(EvaluationClient.fetchLayer).toBeDefined()
|
||||
})
|
||||
|
||||
test("AIClient.layerWith shares one executor across every client", async () => {
|
||||
let built = 0
|
||||
const counting = Layer.effect(
|
||||
RequestExecutor.Service,
|
||||
Effect.sync(() => {
|
||||
built++
|
||||
return RequestExecutor.Service.of({ execute: () => Effect.die("unexpected request") })
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* LLMClient.Service
|
||||
yield* ImageClient.Service
|
||||
yield* VideoClient.Service
|
||||
yield* SpeechClient.Service
|
||||
yield* TranscriptionClient.Service
|
||||
yield* RequestExecutor.Service
|
||||
}).pipe(Effect.provide(AIClient.layerWith(counting))),
|
||||
)
|
||||
expect(built).toBe(1)
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
expect(Route.make).toBeFunction()
|
||||
expect(Protocol.make).toBeFunction()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { AIError, LLMEvent, Media, SpeechEvent, TranscriptionEvent } from "../src/index.js"
|
||||
@@ -182,6 +185,36 @@ describe("AI promise client", () => {
|
||||
await ai.dispose()
|
||||
})
|
||||
|
||||
test("observes a started generation's events and fetches its result", async () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
|
||||
const generation = await ai.video.start({ model, prompt: "A kite" })
|
||||
|
||||
const events: Array<string> = []
|
||||
for await (const event of generation.events({ poll: { interval: 10 } })) events.push(event.type)
|
||||
expect(events).toEqual(["generation-progress", "generation-finished"])
|
||||
expect(generation.status).toBe("queued")
|
||||
expect((await generation.result()).video.source).toMatchObject({ url: "https://runway.test/out.mp4" })
|
||||
await ai.dispose()
|
||||
})
|
||||
|
||||
test("reads, writes, and decodes assets without leaving promises", async () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
const dir = await mkdtemp(join(tmpdir(), "ai-promise-"))
|
||||
try {
|
||||
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3])
|
||||
await Bun.write(join(dir, "source.bin"), png)
|
||||
|
||||
const asset = await ai.file(join(dir, "source.bin"))
|
||||
expect(asset.mediaType).toBe("image/png")
|
||||
await ai.write(asset, join(dir, "copy.png"))
|
||||
expect(await ai.bytes(await ai.file(join(dir, "copy.png")))).toEqual(png)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true })
|
||||
await ai.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("generates, streams, and starts transcriptions over the same runtime", async () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
const audio = Media.url("https://audio.test/hello.mp3")
|
||||
@@ -233,6 +266,12 @@ describe("AI promise client", () => {
|
||||
expect(failure).toBeInstanceOf(AIError)
|
||||
expect(failure instanceof AIError && failure.reason.http?.status).toBe(404)
|
||||
|
||||
const invalid = await ai.llm
|
||||
// @ts-expect-error Invalid input must reject with AIError, not throw synchronously.
|
||||
.generate({ model: openai.responses("gpt-5"), messages: [{ role: "bogus" }] })
|
||||
.catch((error: unknown) => error)
|
||||
expect(invalid instanceof AIError && invalid.reason._tag).toBe("InvalidRequest")
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const aborted = await ai.llm
|
||||
|
||||
@@ -533,6 +533,7 @@ describe("Video / fal", () => {
|
||||
"InvalidRequest",
|
||||
])
|
||||
expect(errors[1].message).toContain("end_image_url")
|
||||
expect(errors[4].message).toContain("; got fal:handle")
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("unsupported input reached the network")))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -94,9 +94,10 @@ In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPEN
|
||||
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
|
||||
from storage. Desktop provides the local server it discovers or starts through native initialization.
|
||||
|
||||
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
|
||||
or choose **Scan QR code** to open the camera and read the pairing code from `opencode pair`.
|
||||
Scanning fills the form and immediately attempts to connect. Failed connections leave the details available
|
||||
With no configured servers, or when the only server rejects the saved credentials, the app shows a full-screen
|
||||
connection form. Enter a server address and password, paste a link from `opencode pair`, or choose
|
||||
**Scan QR code** to read its QR code. Pairing links are single-use; the app exchanges them for a session token
|
||||
and immediately attempts to connect. Failed connections leave the details available
|
||||
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
|
||||
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
|
||||
|
||||
@@ -106,7 +107,7 @@ When the service is exposed through an HTTPS reverse proxy, advertise its extern
|
||||
opencode pair --url https://opencode.example.com
|
||||
```
|
||||
|
||||
This replaces the addresses printed and encoded in the QR code while retaining the local service password.
|
||||
This replaces the addresses in the printed links and QR code.
|
||||
The proxy URL must reach the OpenCode API, not just the frontend. For separate frontend and API processes,
|
||||
route `/api` to the service while preserving the `/api` prefix. No machine-specific app or CLI build is required.
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
|
||||
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
|
||||
const password = "pairing-secret"
|
||||
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
const requests: { origin: string; authorization: string | undefined }[] = []
|
||||
await page.addInitScript((origin) => {
|
||||
if (localStorage.getItem("opencode.global.dat:server")) return
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
|
||||
)
|
||||
}, origin)
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push({
|
||||
origin: new URL(route.request().url()).origin,
|
||||
authorization: route.request().headers().authorization,
|
||||
})
|
||||
// Pairing must succeed even when the API is unavailable.
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
|
||||
})
|
||||
|
||||
await page.goto(`/connect#${Buffer.from(JSON.stringify({ username: "opencode", password })).toString("base64url")}`)
|
||||
await expect(page).toHaveURL(`${origin}/`)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
|
||||
.toEqual([{ type: "http", http: { url: origin, password } }])
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
|
||||
requests.length = 0
|
||||
await page.reload()
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("the unpaired page loads without starting server requests", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
await page.goto("/connect")
|
||||
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
|
||||
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
+16
-24
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
|
||||
import { Font } from "@opencode/ui/font"
|
||||
import { ThemeProvider } from "@opencode/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
@@ -107,29 +107,21 @@ export function AppInterface(props: {
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const Root = (rootProps: ParentProps) => {
|
||||
const location = useLocation()
|
||||
// Pairing saves credentials before mounting any server connections or health checks.
|
||||
return (
|
||||
<>
|
||||
const Root = (rootProps: ParentProps) => (
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<BodyTypography />
|
||||
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
)
|
||||
|
||||
return (
|
||||
<ServersProvider
|
||||
|
||||
@@ -496,6 +496,7 @@ export const dict = {
|
||||
"server.connect.scan": "Scan QR code",
|
||||
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
|
||||
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
|
||||
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
|
||||
"server.connect.camera": "Pairing camera",
|
||||
"server.connect.camera.starting": "Opening camera…",
|
||||
"server.connect.mixedContent":
|
||||
|
||||
@@ -24,8 +24,6 @@ type DesktopOS = "macos" | "windows" | "linux"
|
||||
|
||||
export type PairingInfo = {
|
||||
readonly urls: readonly string[]
|
||||
readonly username: "opencode"
|
||||
readonly password: string
|
||||
}
|
||||
|
||||
export type FatalRendererErrorLog = {
|
||||
@@ -141,6 +139,8 @@ type PlatformBase = {
|
||||
/** Pair another device with the local desktop server. */
|
||||
pair?: {
|
||||
info(): Promise<PairingInfo>
|
||||
/** Single-use code for an `/auth/connect/:code` link. */
|
||||
code(): Promise<string>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ describe("checkServerHealth", () => {
|
||||
expect(headers).toEqual([password ? `Basic ${btoa(`opencode:${password}`)}` : null])
|
||||
})
|
||||
|
||||
test("reports rejected credentials without retrying", async () => {
|
||||
let calls = 0
|
||||
const fetch = (async () => {
|
||||
calls++
|
||||
return Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 })
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: false, unauthorized: true })
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("returns healthy response with version", async () => {
|
||||
let request: URL | undefined
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { authTokenFromCredentials } from "./api"
|
||||
import { ClientError, OpenCode } from "@opencode/client"
|
||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode/client"
|
||||
import { Accessor, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
|
||||
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean; checking?: boolean }
|
||||
export type ServerHealth = {
|
||||
healthy: boolean
|
||||
version?: string
|
||||
incompatible?: boolean
|
||||
checking?: boolean
|
||||
unauthorized?: boolean
|
||||
}
|
||||
|
||||
interface CheckServerHealthOptions {
|
||||
timeoutMs?: number
|
||||
@@ -100,7 +106,7 @@ export async function checkServerHealth(
|
||||
.catch((error) => ({ error }))
|
||||
if ("data" in current) return current.data
|
||||
if (signal?.aborted) return { healthy: false }
|
||||
|
||||
if (isUnauthorizedError(current.error)) return { healthy: false, unauthorized: true }
|
||||
return next(count, current.error)
|
||||
}
|
||||
return attempt(0).finally(() => timeout?.clear?.())
|
||||
|
||||
@@ -48,15 +48,18 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
return serverCtx
|
||||
}
|
||||
|
||||
// A server that rejects our credentials would retry its event stream every second with the same
|
||||
// credentials, so its controller waits until health recovers and then starts with the current ones.
|
||||
createMemo(() => {
|
||||
for (const conn of server.list) {
|
||||
if (serverHealth[ServerConnection.key(conn)]?.unauthorized) continue
|
||||
ensureServerCtx(conn)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
for (const [key] of serverCtxs) {
|
||||
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
|
||||
if (serverHealth[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key)) {
|
||||
serverCtxDisposers.get(key)?.()
|
||||
serverCtxDisposers.delete(key)
|
||||
serverCtxs.delete(key)
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
import type { Pairing } from "./pairing"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
@@ -117,16 +117,10 @@ export const DialogServer: Component<{
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
list="dialog-server-addresses"
|
||||
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<datalist id="dialog-server-addresses">
|
||||
{form.state.urls().map((url) => (
|
||||
<option value={url} />
|
||||
))}
|
||||
</datalist>
|
||||
<Show when={form.state.error()}>
|
||||
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
|
||||
{form.state.error()}
|
||||
@@ -213,7 +207,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list" as FormMode,
|
||||
originalUrl: undefined as string | undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [] as string[],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
@@ -227,7 +220,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list",
|
||||
originalUrl: undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined,
|
||||
@@ -333,11 +325,10 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
setStore("error", "")
|
||||
request.mutate()
|
||||
}
|
||||
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
|
||||
const pair = (pairing: Pairing) => {
|
||||
healthPreview.cancel()
|
||||
setStore({
|
||||
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
|
||||
urls: pairing.urls,
|
||||
values: { ...store.values, url: pairing.url, password: pairing.password },
|
||||
scanning: false,
|
||||
error: "",
|
||||
})
|
||||
@@ -359,7 +350,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
value: () => store.values.url,
|
||||
name: () => store.values.name,
|
||||
password: () => store.values.password,
|
||||
urls: () => store.urls,
|
||||
scanning: () => store.scanning,
|
||||
error: () => store.error,
|
||||
status: () => store.status,
|
||||
|
||||
@@ -1,95 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
|
||||
import { pairingLink } from "./pairing"
|
||||
|
||||
describe("pairing URL", () => {
|
||||
test("pairs with the current origin using credentials without server URLs", () => {
|
||||
const info = { username: "opencode" as const, password: "a+b & café" }
|
||||
const origin = "https://opencode.example.com:49709"
|
||||
const url = new URL(pairingUrl(info, origin))
|
||||
|
||||
expect(url.origin).toBe(origin)
|
||||
expect(url.pathname).toBe("/connect")
|
||||
expect(url.search).toBe("")
|
||||
expect(url.hash).not.toBe("")
|
||||
expect(decodePairingUrl(url.hash, origin)).toEqual({ urls: [origin], password: info.password })
|
||||
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps accepting query pairing data", () => {
|
||||
const info = { urls: ["http://192.168.1.2:4096"], username: "opencode", password: "a+b & café" }
|
||||
expect(decodePairingUrl(`?data=${encodeURIComponent(JSON.stringify(info))}`)).toEqual({
|
||||
urls: info.urls,
|
||||
password: info.password,
|
||||
describe("pairing link", () => {
|
||||
test("reads the server address and code from opencode pair links", () => {
|
||||
expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({
|
||||
url: "http://192.168.1.2:49374",
|
||||
code: "abc_DEF-123",
|
||||
})
|
||||
})
|
||||
|
||||
test("accepts CLI base64url fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
|
||||
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid query data", () => {
|
||||
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
|
||||
expect(decodePairingUrl("?other=value")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("accepts legacy JSON fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
|
||||
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects an invalid fragment", () => {
|
||||
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pairing scan", () => {
|
||||
const info = {
|
||||
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
|
||||
username: "opencode" as const,
|
||||
password: "a+b & café",
|
||||
}
|
||||
|
||||
test("decodes the raw JSON code", () => {
|
||||
expect(decodePairingScan(JSON.stringify(info))).toEqual({ urls: info.urls, password: info.password })
|
||||
})
|
||||
|
||||
test("decodes a direct /connect URL", () => {
|
||||
expect(
|
||||
decodePairingScan(pairingUrl({ username: info.username, password: info.password }, "http://192.168.1.2:49374")),
|
||||
).toEqual({
|
||||
urls: ["http://192.168.1.2:49374"],
|
||||
password: info.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps accepting /connect URLs with query data", () => {
|
||||
expect(
|
||||
decodePairingScan(`http://192.168.1.2:49374/connect?data=${encodeURIComponent(JSON.stringify(info))}`),
|
||||
).toEqual({
|
||||
urls: info.urls,
|
||||
password: info.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to the URL origin when the payload omits server URLs", () => {
|
||||
const origin = "https://opencode.example.com:49709"
|
||||
expect(decodePairingScan(pairingUrl({ username: "opencode", password: "secret" }, origin))).toEqual({
|
||||
urls: [origin],
|
||||
password: "secret",
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects URLs without pairing data and non-http schemes", () => {
|
||||
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
|
||||
expect(decodePairingScan("https://example.com/?data=invalid")).toBeUndefined()
|
||||
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
|
||||
expect(decodePairingScan("not a code")).toBeUndefined()
|
||||
test("rejects other URLs", () => {
|
||||
expect(pairingLink("http://192.168.1.2:49374/auth/connect/")).toBeUndefined()
|
||||
expect(pairingLink("http://192.168.1.2:49374/auth/connect/abc/extra")).toBeUndefined()
|
||||
expect(pairingLink("http://192.168.1.2:49374/connect#abc")).toBeUndefined()
|
||||
expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined()
|
||||
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
const pairing = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
urls: Schema.optional(Schema.Array(Schema.String)),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
}),
|
||||
)
|
||||
|
||||
export function serverAddress(value: string) {
|
||||
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
@@ -20,42 +11,23 @@ export function serverAddress(value: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function decodePairingCode(value: string, origin?: string) {
|
||||
const result = Schema.decodeUnknownOption(pairing)(value)
|
||||
if (Option.isNone(result)) return
|
||||
const urls = [
|
||||
...new Set((result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined)),
|
||||
]
|
||||
if (!urls.length) return
|
||||
return { urls, password: result.value.password }
|
||||
}
|
||||
|
||||
export function pairingUrl(value: { username: "opencode"; password: string }, host: string) {
|
||||
return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}`
|
||||
}
|
||||
|
||||
// Scanned codes are either the raw pairing JSON or a full /connect URL from `opencode pair` or desktop.
|
||||
export function decodePairingScan(value: string) {
|
||||
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
|
||||
export function pairingLink(value: string) {
|
||||
const url = URL.parse(value.trim())
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
|
||||
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash, url.origin)
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
|
||||
const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]
|
||||
const address = serverAddress(url.origin)
|
||||
if (!code || !address) return
|
||||
return { url: address, code }
|
||||
}
|
||||
|
||||
export function decodePairingUrl(value: string, origin?: string) {
|
||||
if (value.startsWith("?")) {
|
||||
const data = new URLSearchParams(value).get("data")
|
||||
return data === null ? undefined : decodePairingCode(data, origin)
|
||||
}
|
||||
const encoded = value.startsWith("#") ? value.slice(1) : value
|
||||
if (!encoded) return
|
||||
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
|
||||
if (legacy.startsWith("{")) return decodePairingCode(legacy)
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
|
||||
const binary = atob(
|
||||
encoded
|
||||
.replaceAll("-", "+")
|
||||
.replaceAll("_", "/")
|
||||
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
|
||||
)
|
||||
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), origin)
|
||||
export type Pairing = { readonly url: string; readonly password: string }
|
||||
|
||||
export function redeemPairingLink(link: { url: string; code: string }) {
|
||||
return OpenCode.make({ baseUrl: link.url })
|
||||
.server.connect({ code: link.code })
|
||||
.then(
|
||||
(session): Pairing => ({ url: link.url, password: session.token }),
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@ import { onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decodePairingScan } from "./pairing"
|
||||
import { pairingLink, redeemPairingLink, type Pairing } from "./pairing"
|
||||
import "./scanner.css"
|
||||
|
||||
export function PairingScanner(props: {
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
export function PairingScanner(props: { onScan: (value: Pairing) => void; onCancel: () => void }) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ error: "", ready: false })
|
||||
const video = document.createElement("video")
|
||||
@@ -22,13 +19,16 @@ export function PairingScanner(props: {
|
||||
const scanner = new QrScanner(
|
||||
video,
|
||||
(result) => {
|
||||
const pairing = decodePairingScan(result.data)
|
||||
if (!pairing) {
|
||||
const link = pairingLink(result.data)
|
||||
if (!link) {
|
||||
setState("error", language.t("server.connect.scan.invalid"))
|
||||
return
|
||||
}
|
||||
scanner.stop()
|
||||
props.onScan(pairing)
|
||||
void redeemPairingLink(link).then((redeemed) => {
|
||||
if (redeemed) return props.onScan(redeemed)
|
||||
setState("error", language.t("server.connect.link.expired"))
|
||||
})
|
||||
},
|
||||
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
|
||||
)
|
||||
|
||||
@@ -8,29 +8,20 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import type { decodePairingCode } from "./pairing"
|
||||
import { pairingLink, redeemPairingLink, serverAddress } from "./pairing"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import "./screen.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
export function ConnectServerScreen(
|
||||
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
|
||||
) {
|
||||
export function ConnectServerScreen(props: { url?: string } = {}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const servers = useServers()
|
||||
const check = useCheckServerHealth()
|
||||
const camera = createCameraAvailability()
|
||||
const [state, setState] = createStore({
|
||||
url: props.pairing?.urls[0] ?? "",
|
||||
password: props.pairing?.password ?? "",
|
||||
urls: props.pairing?.urls ?? ([] as string[]),
|
||||
error: "",
|
||||
scanning: false,
|
||||
})
|
||||
const [state, setState] = createStore({ url: props.url ?? "", password: "", error: "", scanning: false })
|
||||
const connectionError = () =>
|
||||
language.t(
|
||||
platform.platform === "web" && isMixedContent(location.href, state.url)
|
||||
@@ -39,6 +30,16 @@ export function ConnectServerScreen(
|
||||
)
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const link = pairingLink(state.url)
|
||||
if (link) {
|
||||
const redeemed = await redeemPairingLink(link)
|
||||
if (!redeemed) {
|
||||
setState("error", language.t("server.connect.link.expired"))
|
||||
return
|
||||
}
|
||||
// Keep the token in the form so a failed connection check can retry without the spent code.
|
||||
setState({ url: link.url, password: redeemed.password })
|
||||
}
|
||||
const url = serverAddress(state.url)
|
||||
if (!url) {
|
||||
setState("error", language.t("server.connect.address.invalid"))
|
||||
@@ -51,7 +52,6 @@ export function ConnectServerScreen(
|
||||
return
|
||||
}
|
||||
servers.add({ type: "http", http })
|
||||
props.onConnect?.()
|
||||
},
|
||||
onError: () => setState("error", connectionError()),
|
||||
}))
|
||||
@@ -76,13 +76,7 @@ export function ConnectServerScreen(
|
||||
void camera.refetch()
|
||||
}}
|
||||
onScan={(pairing) => {
|
||||
setState({
|
||||
url: pairing.urls[0],
|
||||
urls: pairing.urls,
|
||||
password: pairing.password,
|
||||
error: "",
|
||||
scanning: false,
|
||||
})
|
||||
setState({ url: pairing.url, password: pairing.password, error: "", scanning: false })
|
||||
request.mutate()
|
||||
}}
|
||||
/>
|
||||
@@ -110,18 +104,12 @@ export function ConnectServerScreen(
|
||||
spellcheck={false}
|
||||
required
|
||||
appearance="large"
|
||||
list="server-connect-addresses"
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
value={state.url}
|
||||
disabled={request.isPending}
|
||||
aria-describedby={state.error ? "server-connect-error" : undefined}
|
||||
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
|
||||
/>
|
||||
<datalist id="server-connect-addresses">
|
||||
{state.urls.map((url) => (
|
||||
<option value={url} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="server-connect-field">
|
||||
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
|
||||
|
||||
@@ -8,8 +8,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { renderSVG } from "uqr"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
|
||||
import { pairingUrl } from "@/servers/connect/pairing"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
|
||||
@@ -74,8 +73,8 @@ export function SettingsPairing() {
|
||||
dialog.push(() => (
|
||||
<DialogPairing
|
||||
title={language.t("settings.pairing.connection")}
|
||||
info={localInfo()}
|
||||
host={localHost()!}
|
||||
code={pair.code}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -117,17 +116,19 @@ export function SettingsPairing() {
|
||||
)
|
||||
}
|
||||
|
||||
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host: string }) {
|
||||
function DialogPairing(props: { title: string; host: string; code: () => Promise<string> }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
// Codes are single-use, so keep replacing the link while the dialog is open.
|
||||
const code = useQuery(() => ({
|
||||
queryKey: ["pairing", "code"],
|
||||
queryFn: props.code,
|
||||
gcTime: 0,
|
||||
refetchInterval: 60_000,
|
||||
}))
|
||||
const url = createMemo(() => {
|
||||
if (!props.info) return
|
||||
return pairingUrl({ username: props.info.username, password: props.info.password }, props.host)
|
||||
})
|
||||
const origin = createMemo(() => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
return new URL(value).origin
|
||||
if (!code.isSuccess) return
|
||||
return new URL(`/auth/connect/${code.data}`, props.host).href
|
||||
})
|
||||
const copy = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
@@ -153,7 +154,7 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
|
||||
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
|
||||
<Show when={props.info}>
|
||||
<Show when={url()}>
|
||||
<div
|
||||
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
|
||||
role="img"
|
||||
@@ -176,12 +177,17 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
|
||||
>
|
||||
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
|
||||
<bdi dir="ltr" class="min-w-0 break-all text-start">
|
||||
{origin()}
|
||||
{new URL(props.host).origin}
|
||||
</bdi>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={code.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.error")}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={copy.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.copy.error")}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Route, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Route, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -10,7 +10,6 @@ import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
import Shell from "@/shell/shell"
|
||||
import { requireServerKey } from "./session"
|
||||
import { decodePairingUrl } from "@/servers/connect/pairing"
|
||||
import { DesktopPairingCommand } from "@/shell/commands/desktop"
|
||||
|
||||
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
|
||||
@@ -28,7 +27,6 @@ export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
|
||||
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
return Promise.resolve()
|
||||
@@ -36,48 +34,29 @@ export function preloadRoute(url: string) {
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/connect" component={ConnectRoute} />
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectRoute() {
|
||||
const navigate = useNavigate()
|
||||
const servers = useServers()
|
||||
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash, location.origin)
|
||||
onMount(() => {
|
||||
if (!pairing) return
|
||||
servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } })
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
return (
|
||||
<Show when={!pairing}>
|
||||
<ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
|
||||
</Show>
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,8 +76,15 @@ function TargetServerRoute(props: ParentProps) {
|
||||
|
||||
function AppLayout(props: ParentProps) {
|
||||
const servers = useServers()
|
||||
const global = useGlobal()
|
||||
// A lone server that rejects our credentials (e.g. the web app before pairing) has nothing else to show.
|
||||
const signedOut = () => {
|
||||
const only = servers.list.length === 1 ? servers.list[0] : undefined
|
||||
if (only?.type !== "http") return
|
||||
return global.servers.health[ServerConnection.key(only)]?.unauthorized ? only : undefined
|
||||
}
|
||||
return (
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<Show when={servers.list.length > 0 && !signedOut()} fallback={<ConnectServerScreen url={signedOut()?.http.url} />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<DesktopPairingCommand />
|
||||
|
||||
@@ -18,7 +18,8 @@ export function serviceWorker(directory: string) {
|
||||
skipWaiting: false,
|
||||
inlineWorkboxRuntime: true,
|
||||
navigateFallback: "/index.html",
|
||||
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
|
||||
// Pairing links must reach the server so it can set the session cookie.
|
||||
navigateFallbackDenylist: [/^\/(?:api|auth)(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
|
||||
// Include lazy chunks and non-JS dependencies, not just the startup bundle.
|
||||
globPatterns: ["**/*"],
|
||||
globIgnores: ["**/*.map", "_headers", "_redirects"],
|
||||
|
||||
@@ -495,10 +495,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
},
|
||||
}),
|
||||
Spec.make("pair", {
|
||||
description: "Show server pairing information",
|
||||
description: "Print one-time links to connect a browser or app",
|
||||
params: {
|
||||
url: Flag.string("url").pipe(
|
||||
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
|
||||
Flag.withDescription("Use an external HTTP(S) server URL in pairing links"),
|
||||
Flag.mapTryCatch(
|
||||
(value) => {
|
||||
const url = new URL(value)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EOL } from "os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
@@ -12,34 +11,25 @@ export default Runtime.handler(
|
||||
Commands.commands.pair,
|
||||
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const password = yield* ServiceConfig.password()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const urls = Option.isSome(input.url)
|
||||
? [input.url.value]
|
||||
: (yield* Effect.tryPromise(() =>
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
|
||||
)).urls
|
||||
const info = { urls, username: "opencode", password }
|
||||
const link = info.urls[0]
|
||||
? `${new URL("/connect", info.urls[0])}#${base64Encode(JSON.stringify({ username: info.username, password }))}`
|
||||
: undefined
|
||||
: (yield* Effect.tryPromise(() => client.server.info())).urls
|
||||
const pairing = yield* Effect.tryPromise(() => client.server.pair())
|
||||
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
` URLs ${info.urls[0] ?? "(none)"}`,
|
||||
...info.urls.slice(1).map((url) => ` ${url}`),
|
||||
` Username ${info.username}`,
|
||||
` Password ${info.password}`,
|
||||
...(link
|
||||
` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`,
|
||||
"",
|
||||
...(links.length ? links.map((link) => ` ${link}`) : [" (no server URLs)"]),
|
||||
...(links[0]
|
||||
? [
|
||||
"",
|
||||
" Scan to pair",
|
||||
"",
|
||||
renderUnicodeCompact(link, { border: 2 })
|
||||
renderUnicodeCompact(links[0], { border: 2 })
|
||||
.split(EOL)
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
"",
|
||||
` Link ${link}`,
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
@@ -47,8 +37,17 @@ export default Runtime.handler(
|
||||
)
|
||||
|
||||
if (Option.isSome(input.url)) return
|
||||
const hostname = new URL(endpoint.url).hostname
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
|
||||
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`)
|
||||
const url = new URL(endpoint.url)
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return
|
||||
process.stderr.write(
|
||||
[
|
||||
` Over SSH? Forward the port, then open the link on your machine:`,
|
||||
` ssh -L ${url.port}:${url.hostname}:${url.port} <host>`,
|
||||
` If port ${url.port} is busy locally, forward another port and use it in the link.`,
|
||||
"",
|
||||
" To connect from other devices, run `opencode service set hostname 0.0.0.0`.",
|
||||
"",
|
||||
].join(EOL) + EOL,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,8 +13,13 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
|
||||
// Serve the web shell before API authentication so a signed-out browser gets the app's sign-in screen.
|
||||
if (
|
||||
url.pathname === "/api" ||
|
||||
url.pathname.startsWith("/api/") ||
|
||||
url.pathname.startsWith("/auth/") ||
|
||||
url.pathname === "/openapi.json"
|
||||
)
|
||||
return yield* api.pipe(
|
||||
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("web UI", () => {
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"/",
|
||||
"/connect?data=%7B%7D",
|
||||
"/settings",
|
||||
"/workspace/example",
|
||||
"/_assets/app.js",
|
||||
"/_assets/app.css",
|
||||
@@ -68,6 +68,24 @@ describe("web UI", () => {
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
|
||||
|
||||
const pairing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/pair", origin), {
|
||||
method: "POST",
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}).then((response) => response.json() as Promise<{ code: string }>),
|
||||
)
|
||||
const redirect = yield* Effect.promise(() =>
|
||||
fetch(new URL(`/auth/connect/${pairing.code}`, origin), {
|
||||
redirect: "manual",
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
)
|
||||
expect(redirect.status).toBe(302)
|
||||
const cookie = (redirect.headers.get("set-cookie") ?? "").split(";")[0]
|
||||
const authorized = yield* Effect.promise(() => fetch(new URL("/api/info", origin), { headers: { cookie } }))
|
||||
expect(authorized.status).toBe(200)
|
||||
yield* Effect.promise(() => authorized.arrayBuffer())
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
|
||||
@@ -47,8 +47,17 @@ export type ServerInfoOutput = {
|
||||
}
|
||||
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
|
||||
|
||||
export type ServerPairOutput = { readonly code: string; readonly expires_in: number }
|
||||
export type ServerPairOperation<E = never> = () => Effect.Effect<ServerPairOutput, E>
|
||||
|
||||
export type ServerConnectInput = { readonly code: string }
|
||||
export type ServerConnectOutput = { readonly token: string }
|
||||
export type ServerConnectOperation<E = never> = (input: ServerConnectInput) => Effect.Effect<ServerConnectOutput, E>
|
||||
|
||||
export interface ServerApi<E = never> {
|
||||
readonly info: ServerInfoOperation<E>
|
||||
readonly pair: ServerPairOperation<E>
|
||||
readonly connect: ServerConnectOperation<E>
|
||||
}
|
||||
|
||||
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
|
||||
@@ -6,6 +6,9 @@ import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../../contract"
|
||||
import type {
|
||||
ServerInfoOutput,
|
||||
ServerPairOutput,
|
||||
ServerConnectInput,
|
||||
ServerConnectOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
@@ -281,7 +284,19 @@ const preserveStream =
|
||||
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
|
||||
const EndpointServerPair = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerPairOutput>()(raw["server.pair"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const EndpointServerConnect = (raw: RawClient["server.server"]) => (input: ServerConnectInput) =>
|
||||
preserveEffect<ServerConnectOutput>()(
|
||||
raw["server.connect"]({ params: { code: input["code"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({
|
||||
info: EndpointServerInfo(raw),
|
||||
pair: EndpointServerPair(raw),
|
||||
connect: EndpointServerConnect(raw),
|
||||
})
|
||||
|
||||
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
|
||||
preserveEffect<LocationGetOutput>()(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
ServerInfoOutput,
|
||||
ServerPairOutput,
|
||||
ServerConnectInput,
|
||||
ServerConnectOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
@@ -416,6 +419,22 @@ export function make(options: ClientOptions) {
|
||||
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
pair: (requestOptions?: RequestOptions) =>
|
||||
request<ServerPairOutput>(
|
||||
{ method: "POST", path: `/api/pair`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
connect: (input: ServerConnectInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerConnectOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/auth/connect/${encodeURIComponent(input.code)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
location: {
|
||||
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -2,6 +2,10 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
|
||||
|
||||
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
|
||||
|
||||
export type PairingCode = { code: string; expires_in: number }
|
||||
|
||||
export type PairingSession = { token: string }
|
||||
|
||||
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
|
||||
|
||||
export type LocationPublicRef = { directory: string }
|
||||
@@ -2696,6 +2700,12 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
|
||||
export type ServerInfoOutput = ServerInfo
|
||||
|
||||
export type ServerPairOutput = PairingCode
|
||||
|
||||
export type ServerConnectInput = { readonly code: { readonly code: string }["code"] }
|
||||
|
||||
export type ServerConnectOutput = PairingSession
|
||||
|
||||
export type LocationGetInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -76,6 +76,7 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
}),
|
||||
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
|
||||
AppPairInfo: () => pair(pairing.info),
|
||||
AppPairCode: () => pair(pairing.code),
|
||||
AppGetKeepScreenActive: () => Effect.sync(screenActivity.get),
|
||||
AppSetKeepScreenActive: ({ enabled }) =>
|
||||
Effect.try(() => screenActivity.set(enabled)).pipe(Effect.mapError(String)),
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
export function createPairing() {
|
||||
const requireCredentials = () => {
|
||||
const client = async () => {
|
||||
const credentials = SidecarCredentials.get()
|
||||
if (!credentials) throw new Error("The local desktop server is not ready")
|
||||
return credentials
|
||||
}
|
||||
const readInfo = async (credentials: ReturnType<typeof requireCredentials>) => {
|
||||
const { OpenCode } = await import("@opencode/client/promise")
|
||||
const info = await OpenCode.make({
|
||||
return OpenCode.make({
|
||||
baseUrl: credentials.url,
|
||||
headers: credentials.password
|
||||
? { Authorization: `Basic ${Buffer.from(`opencode:${credentials.password}`).toString("base64")}` }
|
||||
: undefined,
|
||||
}).server.info()
|
||||
return { urls: info.urls, username: "opencode" as const, password: credentials.password ?? "" }
|
||||
})
|
||||
}
|
||||
const info = () => readInfo(requireCredentials())
|
||||
return { info }
|
||||
const info = async () => ({ urls: (await (await client()).server.info()).urls })
|
||||
const code = async () => (await (await client()).server.pair()).code
|
||||
return { info, code }
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export type ElectronAPI = {
|
||||
recordFatalRendererError(error: FatalRendererError): Promise<void>
|
||||
setNativeTranslations(bundle: DesktopNativeBundle): Promise<void>
|
||||
pairInfo(): Promise<typeof PairingInfo.Type>
|
||||
pairCode(): Promise<string>
|
||||
getKeepScreenActive(): Promise<boolean>
|
||||
setKeepScreenActive(enabled: boolean): Promise<void>
|
||||
}
|
||||
|
||||
@@ -163,6 +163,7 @@ export const api: ElectronAPI = {
|
||||
recordFatalRendererError: (error) => invoke("AppRecordFatalRendererError", { error }),
|
||||
setNativeTranslations: (bundle) => invoke("AppSetNativeTranslations", { value: bundle }),
|
||||
pairInfo: () => invoke("AppPairInfo").then(mutable),
|
||||
pairCode: () => invoke("AppPairCode"),
|
||||
getKeepScreenActive: () => invoke("AppGetKeepScreenActive"),
|
||||
setKeepScreenActive: (enabled) => invoke("AppSetKeepScreenActive", { enabled }),
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export function createDesktopPlatform(
|
||||
},
|
||||
pair: {
|
||||
info: () => api.pairInfo(),
|
||||
code: () => api.pairCode(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ const ServerReadyData = Schema.Struct({
|
||||
|
||||
export const PairingInfo = Schema.Struct({
|
||||
urls: Schema.Array(Schema.String),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
})
|
||||
|
||||
export const AppAwaitInitialization = Rpc.make("AppAwaitInitialization", { success: ServerReadyData })
|
||||
@@ -60,6 +58,7 @@ export const AppSetNativeTranslations = Rpc.make("AppSetNativeTranslations", {
|
||||
})
|
||||
export const AppRelaunch = Rpc.make("AppRelaunch")
|
||||
export const AppPairInfo = Rpc.make("AppPairInfo", { success: PairingInfo, error: Schema.String })
|
||||
export const AppPairCode = Rpc.make("AppPairCode", { success: Schema.String, error: Schema.String })
|
||||
export const AppGetKeepScreenActive = Rpc.make("AppGetKeepScreenActive", { success: Schema.Boolean })
|
||||
export const AppSetKeepScreenActive = Rpc.make("AppSetKeepScreenActive", {
|
||||
payload: { enabled: Schema.Boolean },
|
||||
@@ -82,6 +81,7 @@ export const AppRpcs = RpcGroup.make(
|
||||
AppSetNativeTranslations,
|
||||
AppRelaunch,
|
||||
AppPairInfo,
|
||||
AppPairCode,
|
||||
AppGetKeepScreenActive,
|
||||
AppSetKeepScreenActive,
|
||||
)
|
||||
|
||||
@@ -499,6 +499,16 @@ export interface UI {
|
||||
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
|
||||
close(sessionID?: string): boolean
|
||||
}
|
||||
readonly model: {
|
||||
/** The prompt's selected model; variant is undefined for the model default. Reactive when read in a Solid computation. */
|
||||
current(): { readonly providerID: string; readonly modelID: string; readonly variant?: string } | undefined
|
||||
readonly variant: {
|
||||
/** Variant IDs of the selected model. Reactive when read in a Solid computation. */
|
||||
list(): readonly string[]
|
||||
/** Selects a variant of the selected model, or the model default when undefined. Returns false when no model is selected or the variant is unavailable. */
|
||||
set(variant: string | undefined): boolean
|
||||
}
|
||||
}
|
||||
/** Claims a place in the slot tree; see SlotClaim. */
|
||||
readonly slot: (claim: SlotClaim) => () => void
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { UnauthorizedError } from "../errors.js"
|
||||
|
||||
export const ServerInfo = Schema.Struct({
|
||||
version: Schema.String,
|
||||
@@ -12,6 +13,24 @@ export const ServerInfo = Schema.Struct({
|
||||
}).annotate({ identifier: "ServerInfo" })
|
||||
export type ServerInfo = typeof ServerInfo.Type
|
||||
|
||||
export const PairingCode = Schema.Struct({
|
||||
code: Schema.String,
|
||||
expires_in: Schema.Int,
|
||||
}).annotate({ identifier: "PairingCode" })
|
||||
export type PairingCode = typeof PairingCode.Type
|
||||
|
||||
export const PairingSession = Schema.Struct({
|
||||
token: Schema.String,
|
||||
}).annotate({ identifier: "PairingSession" })
|
||||
export type PairingSession = typeof PairingSession.Type
|
||||
|
||||
const PAIRING_CONNECT_PATH = /^\/auth\/connect\/[^/]+$/
|
||||
|
||||
// Authorization middleware skips credential checks for pairing links; the connect handler consumes the code instead.
|
||||
export function isPairingConnectURL(url: URL) {
|
||||
return PAIRING_CONNECT_PATH.test(url.pathname)
|
||||
}
|
||||
|
||||
export const ServerGroup = HttpApiGroup.make("server.server")
|
||||
.add(
|
||||
HttpApiEndpoint.get("server.info", "/api/info", {
|
||||
@@ -24,4 +43,29 @@ export const ServerGroup = HttpApiGroup.make("server.server")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("server.pair", "/api/pair", {
|
||||
success: PairingCode,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "server.pair",
|
||||
summary: "Create pairing code",
|
||||
description: "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("server.connect", "/auth/connect/:code", {
|
||||
params: { code: Schema.String },
|
||||
success: PairingSession,
|
||||
error: UnauthorizedError,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "server.connect",
|
||||
summary: "Redeem pairing code",
|
||||
description:
|
||||
"Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "server" }))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as ServerAuth from "./auth"
|
||||
|
||||
import { Context, Layer, Option, Redacted } from "effect"
|
||||
import { createHmac, timingSafeEqual } from "node:crypto"
|
||||
|
||||
export type DecodedCredentials = {
|
||||
readonly username: string
|
||||
@@ -12,6 +13,8 @@ export type Info = {
|
||||
readonly username: string
|
||||
}
|
||||
|
||||
export const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
|
||||
|
||||
export class Config extends Context.Service<Config, Info>()("@opencode/ServerAuthConfig") {
|
||||
static configLayer(input: Pick<Info, "password">) {
|
||||
return Layer.succeed(this, this.of({ ...input, username: "opencode" }))
|
||||
@@ -26,10 +29,38 @@ export function required(config: Info) {
|
||||
return Option.isSome(config.password) && config.password.value !== ""
|
||||
}
|
||||
|
||||
// Session tokens issued by pairing links are accepted anywhere the password is.
|
||||
export function authorized(credentials: DecodedCredentials, config: Info) {
|
||||
return (
|
||||
Option.isSome(config.password) &&
|
||||
credentials.username === config.username &&
|
||||
Redacted.value(credentials.password) === config.password.value
|
||||
)
|
||||
if (Option.isNone(config.password) || credentials.username !== config.username) return false
|
||||
const password = Redacted.value(credentials.password)
|
||||
return password === config.password.value || verifySession(password, config)
|
||||
}
|
||||
|
||||
// Sessions are signed with a key derived from the server password, so rotating the password revokes every session.
|
||||
export function issueSession(config: Info, now = Date.now()) {
|
||||
if (Option.isNone(config.password)) return
|
||||
const expires = String(Math.floor(now / 1000) + SESSION_TTL_SECONDS)
|
||||
return `${expires}.${sign(config.password.value, expires)}`
|
||||
}
|
||||
|
||||
export function verifySession(token: string, config: Info, now = Date.now()) {
|
||||
if (Option.isNone(config.password)) return false
|
||||
const parts = token.split(".")
|
||||
if (parts.length !== 2) return false
|
||||
const expires = Number(parts[0])
|
||||
if (!Number.isSafeInteger(expires) || expires * 1000 <= now) return false
|
||||
const expected = Buffer.from(sign(config.password.value, parts[0]))
|
||||
const actual = Buffer.from(parts[1])
|
||||
return actual.length === expected.length && timingSafeEqual(actual, expected)
|
||||
}
|
||||
|
||||
// Browsers share cookies across ports on the same host, so the name carries the port to keep local servers apart.
|
||||
export function sessionCookieName(host: string | undefined) {
|
||||
const port = URL.parse(`http://${host ?? ""}`)?.port
|
||||
return port ? `opencode_session_${port}` : "opencode_session"
|
||||
}
|
||||
|
||||
function sign(password: string, payload: string) {
|
||||
const key = createHmac("sha256", password).update("opencode-session-v1").digest()
|
||||
return createHmac("sha256", key).update(payload).digest("base64url")
|
||||
}
|
||||
|
||||
@@ -1,18 +1,54 @@
|
||||
import { Effect } from "effect"
|
||||
import { Duration, Effect } from "effect"
|
||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { UnauthorizedError } from "@opencode/protocol/errors"
|
||||
import { Api } from "../api"
|
||||
import { ServerAuth } from "../auth"
|
||||
import { ServerInfo } from "../server-info"
|
||||
import { ServerPairing } from "../pairing"
|
||||
|
||||
export const ServerHandler = HttpApiBuilder.group(Api, "server.server", (handlers) =>
|
||||
handlers.handle("server.info", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
version: info.app.version ?? "unknown",
|
||||
pid: process.pid ?? 0,
|
||||
urls: info.urls(),
|
||||
paths: info.paths,
|
||||
}
|
||||
}),
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
const pairing = yield* ServerPairing.Service
|
||||
const auth = yield* ServerAuth.Config
|
||||
|
||||
return handlers
|
||||
.handle("server.info", () =>
|
||||
Effect.gen(function* () {
|
||||
const info = yield* ServerInfo.Service
|
||||
return {
|
||||
version: info.app.version ?? "unknown",
|
||||
pid: process.pid ?? 0,
|
||||
urls: info.urls(),
|
||||
paths: info.paths,
|
||||
}
|
||||
}),
|
||||
)
|
||||
.handle("server.pair", () => pairing.issue())
|
||||
.handle(
|
||||
"server.connect",
|
||||
Effect.fn(function* (ctx) {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
// Browser navigations ask for HTML; everything else is an API client that wants the token.
|
||||
const browser = request.headers.accept?.includes("text/html") === true
|
||||
const token = (yield* pairing.consume(ctx.params.code)) ? ServerAuth.issueSession(auth) : undefined
|
||||
if (token === undefined) {
|
||||
if (!browser) return yield* new UnauthorizedError({ message: "Pairing link expired or already used" })
|
||||
return HttpServerResponse.text(
|
||||
"This pairing link expired or was already used. Run `opencode pair` to get a new one.",
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
if (!browser) return { token }
|
||||
return HttpServerResponse.redirect("/").pipe(
|
||||
HttpServerResponse.setCookieUnsafe(ServerAuth.sessionCookieName(request.headers.host), token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: Duration.seconds(ServerAuth.SESSION_TTL_SECONDS),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Authorization } from "@opencode/protocol/middleware/authorization"
|
||||
export { Authorization } from "@opencode/protocol/middleware/authorization"
|
||||
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
|
||||
import { isPairingConnectURL } from "@opencode/protocol/groups/server"
|
||||
import { Effect, Encoding, Layer, Redacted } from "effect"
|
||||
import { HttpEffect, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
@@ -36,8 +37,36 @@ function credentialFromRequest(request: HttpServerRequest.HttpServerRequest) {
|
||||
return Effect.succeed(emptyCredential())
|
||||
}
|
||||
|
||||
const UNAUTHORIZED_MESSAGE = "Authentication required"
|
||||
|
||||
// Browsers show a native credentials prompt for a Basic challenge even on fetch, which would stall the web app
|
||||
// before it can show its own sign-in screen. Only non-browser clients and page navigations get the challenge.
|
||||
function challengeRequest(request: HttpServerRequest.HttpServerRequest) {
|
||||
const mode = request.headers["sec-fetch-mode"]
|
||||
return mode === undefined || mode === "navigate"
|
||||
}
|
||||
|
||||
// Matches what the Authorization middleware encodes, for requests rejected before the HttpApi runs.
|
||||
export function unauthorizedResponse(request: HttpServerRequest.HttpServerRequest) {
|
||||
return HttpServerResponse.jsonUnsafe(
|
||||
{ _tag: "UnauthorizedError", message: UNAUTHORIZED_MESSAGE },
|
||||
{ status: 401, headers: challengeRequest(request) ? { "www-authenticate": WWW_AUTHENTICATE } : undefined },
|
||||
)
|
||||
}
|
||||
|
||||
export function authorizedRequest(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) {
|
||||
return credentialFromRequest(request).pipe(Effect.map((credential) => ServerAuth.authorized(credential, config)))
|
||||
return credentialFromRequest(request).pipe(
|
||||
Effect.map((credential) => ServerAuth.authorized(credential, config) || authorizedSessionCookie(request, config)),
|
||||
)
|
||||
}
|
||||
|
||||
function authorizedSessionCookie(request: HttpServerRequest.HttpServerRequest, config: ServerAuth.Info) {
|
||||
const token = request.cookies[ServerAuth.sessionCookieName(request.headers.host)]
|
||||
if (!token) return false
|
||||
// Same-site pages on other ports still send this cookie, so only same-origin requests may use it.
|
||||
const origin = request.headers.origin
|
||||
if (origin !== undefined && URL.parse(origin)?.host !== request.headers.host) return false
|
||||
return ServerAuth.verifySession(token, config)
|
||||
}
|
||||
|
||||
export const authorizationLayer = Layer.effect(
|
||||
@@ -48,15 +77,17 @@ export const authorizationLayer = Layer.effect(
|
||||
return Authorization.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
// Browsers cannot set headers on WebSocket upgrades, so a ticketed PTY connect skips
|
||||
// credential checks here; the connect handler consumes and validates the ticket.
|
||||
// Ticketed PTY connects (browsers cannot set headers on WebSocket upgrades) and pairing links
|
||||
// skip credential checks here; their handlers consume and validate the ticket or code.
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url)) return yield* effect
|
||||
if (hasPtyConnectTicketURL(url) || hasPersistentPtyConnectTicketURL(url) || isPairingConnectURL(url))
|
||||
return yield* effect
|
||||
if (yield* authorizedRequest(request, config)) return yield* effect
|
||||
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
|
||||
)
|
||||
return yield* new UnauthorizedError({ message: "Authentication required" })
|
||||
if (challengeRequest(request))
|
||||
yield* HttpEffect.appendPreResponseHandler((_request, response) =>
|
||||
Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
|
||||
)
|
||||
return yield* new UnauthorizedError({ message: UNAUTHORIZED_MESSAGE })
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
export * as ServerPairing from "./pairing"
|
||||
|
||||
import { Cache, Context, Duration, Effect, Layer } from "effect"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { randomBytes } from "node:crypto"
|
||||
|
||||
const TTL = Duration.minutes(5)
|
||||
|
||||
export interface Interface {
|
||||
readonly issue: () => Effect.Effect<{ readonly code: string; readonly expires_in: number }>
|
||||
readonly consume: (code: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ServerPairing") {}
|
||||
|
||||
// Codes are inserted via Cache.set and removed via invalidateWhen, so the lookup never runs.
|
||||
const noLookup = () => Effect.die(new Error("ServerPairing cache must be used via set/invalidateWhen, never get"))
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* Cache.make<string, true>({ capacity: 1_000, lookup: noLookup, timeToLive: TTL })
|
||||
return Service.of({
|
||||
issue: Effect.fn("ServerPairing.issue")(function* () {
|
||||
const code = randomBytes(16).toString("base64url")
|
||||
yield* Cache.set(cache, code, true)
|
||||
return { code, expires_in: Duration.toSeconds(TTL) }
|
||||
}),
|
||||
consume: Effect.fn("ServerPairing.consume")(function* (code) {
|
||||
return yield* Cache.invalidateWhen(cache, code, () => true)
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeGlobalNode({ service: Service, layer, deps: [] })
|
||||
@@ -6,13 +6,14 @@ import { SessionRestart } from "@opencode/core/session/execution/restart"
|
||||
import { InstallationEvent } from "@opencode/schema/installation-event"
|
||||
import { hasPtyConnectTicketURL } from "@opencode/protocol/groups/pty"
|
||||
import { hasPersistentPtyConnectTicketURL } from "@opencode/protocol/groups/persistent-pty"
|
||||
import { isPairingConnectURL } from "@opencode/protocol/groups/server"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
import { isAllowedCorsOrigin } from "./cors"
|
||||
import { authorizedRequest } from "./middleware/authorization"
|
||||
import { authorizedRequest, unauthorizedResponse } from "./middleware/authorization"
|
||||
import { withoutParentSpan } from "./request-tracing"
|
||||
import { createRoutes } from "./routes"
|
||||
import { ServerInfo } from "./server-info"
|
||||
@@ -182,26 +183,20 @@ function dispatch(
|
||||
const app = yield* Ref.get(application)
|
||||
const ready = state.type === "ready" && Option.isSome(app)
|
||||
if (request.method === "GET" && url.pathname === "/api/info" && !ready) {
|
||||
if (!(yield* authorizedRequest(request, auth))) return unauthorized()
|
||||
if (!(yield* authorizedRequest(request, auth))) return unauthorizedResponse(request)
|
||||
return yield* infoResponse(status, version, urls, tmp)
|
||||
}
|
||||
if (
|
||||
!isPairingConnectURL(url) &&
|
||||
(!ready || (!hasPtyConnectTicketURL(url) && !hasPersistentPtyConnectTicketURL(url))) &&
|
||||
!(yield* authorizedRequest(request, auth))
|
||||
)
|
||||
return unauthorized()
|
||||
return unauthorizedResponse(request)
|
||||
if (ready) return yield* app.value
|
||||
return unavailable(state)
|
||||
})
|
||||
}
|
||||
|
||||
function unauthorized() {
|
||||
return HttpServerResponse.empty({
|
||||
status: 401,
|
||||
headers: { "www-authenticate": 'Basic realm="Secure Area"' },
|
||||
})
|
||||
}
|
||||
|
||||
const infoResponse = Effect.fnUntraced(function* (
|
||||
status: Status.Interface,
|
||||
version: string,
|
||||
|
||||
@@ -42,6 +42,7 @@ import { handlers } from "./handlers"
|
||||
import { authorizationLayer } from "./middleware/authorization"
|
||||
import { schemaErrorLayer } from "./middleware/schema-error"
|
||||
import { PtyEnvironment } from "./pty-environment"
|
||||
import { ServerPairing } from "./pairing"
|
||||
import { layer } from "./location"
|
||||
import { formLocationLayer } from "./middleware/form-location"
|
||||
import { sessionLocationLayer } from "./middleware/session-location"
|
||||
@@ -68,6 +69,7 @@ const applicationServiceNodes = [
|
||||
Credential.node,
|
||||
WellKnown.node,
|
||||
PtyEnvironment.node,
|
||||
ServerPairing.node,
|
||||
LocationServiceMap.node,
|
||||
LocationActivity.node,
|
||||
SessionRestart.node,
|
||||
|
||||
@@ -7,3 +7,18 @@ test("accepts only the fixed opencode username", () => {
|
||||
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make("secret") }, config)).toBe(true)
|
||||
expect(ServerAuth.authorized({ username: "custom", password: Redacted.make("secret") }, config)).toBe(false)
|
||||
})
|
||||
|
||||
test("session tokens expire, resist tampering, and are revoked by changing the password", () => {
|
||||
const config = { password: Option.some("secret"), username: "opencode" }
|
||||
const now = Date.now()
|
||||
const token = ServerAuth.issueSession(config, now)
|
||||
if (!token) throw new Error("Expected a session token")
|
||||
expect(ServerAuth.verifySession(token, config, now)).toBe(true)
|
||||
expect(ServerAuth.authorized({ username: "opencode", password: Redacted.make(token) }, config)).toBe(true)
|
||||
expect(ServerAuth.verifySession(token, config, now + ServerAuth.SESSION_TTL_SECONDS * 1000)).toBe(false)
|
||||
expect(ServerAuth.verifySession(`${token}x`, config, now)).toBe(false)
|
||||
const parts = token.split(".")
|
||||
expect(ServerAuth.verifySession(`${Number(parts[0]) + 1}.${parts[1]}`, config, now)).toBe(false)
|
||||
expect(ServerAuth.verifySession(token, { ...config, password: Option.some("rotated") }, now)).toBe(false)
|
||||
expect(ServerAuth.issueSession({ ...config, password: Option.none() })).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -128,7 +128,10 @@ it.live("authenticates API requests behind the frontend transform while allowing
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => fetch(new URL(pathname, HttpServer.formatAddress(server.address))))
|
||||
expect(response.status).toBe(401)
|
||||
expect(yield* Effect.promise(() => response.text())).toBe("")
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
_tag: "UnauthorizedError",
|
||||
message: "Authentication required",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -164,6 +167,68 @@ it.live("authenticates API requests behind the frontend transform while allowing
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("pairing links sign in browsers with a cookie and API clients with a token", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const request = (pathname: string, init?: RequestInit) =>
|
||||
Effect.promise(() => fetch(new URL(pathname, base), { redirect: "manual", ...init }))
|
||||
const pair = Effect.gen(function* () {
|
||||
const response = yield* request("/api/pair", {
|
||||
method: "POST",
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
return (yield* Effect.promise(() => response.json())) as { code: string; expires_in: number }
|
||||
})
|
||||
|
||||
const rejected = yield* request("/api/pair", { method: "POST" })
|
||||
expect(rejected.status).toBe(401)
|
||||
expect(rejected.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
|
||||
// A Basic challenge on fetch makes browsers show a native prompt instead of the app's sign-in screen.
|
||||
const fetched = yield* request("/api/info", { headers: { "sec-fetch-mode": "cors" } })
|
||||
expect(fetched.status).toBe(401)
|
||||
expect(fetched.headers.get("www-authenticate")).toBeNull()
|
||||
|
||||
const browser = yield* pair
|
||||
expect(browser.expires_in).toBe(300)
|
||||
const redirect = yield* request(`/auth/connect/${browser.code}`, { headers: { accept: "text/html" } })
|
||||
expect(redirect.status).toBe(302)
|
||||
expect(redirect.headers.get("location")).toBe("/")
|
||||
const setCookie = redirect.headers.get("set-cookie") ?? ""
|
||||
expect(setCookie).toContain(`opencode_session_${new URL(base).port}=`)
|
||||
expect(setCookie).toContain("HttpOnly")
|
||||
expect(setCookie).toContain("SameSite=Lax")
|
||||
const cookie = setCookie.split(";")[0]
|
||||
|
||||
const reused = yield* request(`/auth/connect/${browser.code}`, { headers: { accept: "text/html" } })
|
||||
expect(reused.status).toBe(401)
|
||||
expect(yield* Effect.promise(() => reused.text())).toContain("opencode pair")
|
||||
|
||||
expect((yield* request("/api/info", { headers: { cookie } })).status).toBe(200)
|
||||
expect((yield* request("/api/info", { headers: { cookie, origin: base } })).status).toBe(200)
|
||||
expect((yield* request("/api/info", { headers: { cookie, origin: "http://127.0.0.1:1" } })).status).toBe(401)
|
||||
expect((yield* request("/api/info", { headers: { cookie: `${cookie}x` } })).status).toBe(401)
|
||||
|
||||
const client = yield* pair
|
||||
const redeemed = yield* request(`/auth/connect/${client.code}`)
|
||||
expect(redeemed.status).toBe(200)
|
||||
const session = (yield* Effect.promise(() => redeemed.json())) as { token: string }
|
||||
expect(
|
||||
(yield* request("/api/info", { headers: { authorization: `Basic ${btoa(`opencode:${session.token}`)}` } }))
|
||||
.status,
|
||||
).toBe(200)
|
||||
expect((yield* request(`/auth/connect/${client.code}`)).status).toBe(401)
|
||||
expect((yield* request("/auth/connect/unknown")).status).toBe(401)
|
||||
}),
|
||||
)
|
||||
|
||||
async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, expected: string) {
|
||||
while (true) {
|
||||
const next = await reader.read()
|
||||
|
||||
@@ -63,7 +63,7 @@ import { DialogMcp } from "./component/dialog-mcp"
|
||||
import { DialogStatus } from "./component/dialog-status"
|
||||
import { DialogConfig } from "./component/dialog-config"
|
||||
import { DialogDebug } from "./component/dialog-debug"
|
||||
import { DialogPair, type DialogPairCredentials } from "./component/dialog-pair"
|
||||
import { DialogPair } from "./component/dialog-pair"
|
||||
import { DialogThemeList } from "./component/dialog-theme-list"
|
||||
import { DialogHelp } from "./ui/dialog-help"
|
||||
import { DialogAgent } from "./component/dialog-agent"
|
||||
@@ -403,16 +403,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
packages={input.packages}
|
||||
directories={pluginDirectories}
|
||||
>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
<App />
|
||||
</PluginProvider>
|
||||
</PanelProvider>
|
||||
</UpdateNotificationProvider>
|
||||
@@ -465,7 +456,7 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
})
|
||||
})
|
||||
|
||||
function App(props: { pair?: DialogPairCredentials }) {
|
||||
function App() {
|
||||
const log = useLog({ component: "app" })
|
||||
const app = useTuiApp()
|
||||
const startup = useTuiStartup()
|
||||
@@ -1007,7 +998,7 @@ function App(props: { pair?: DialogPairCredentials }) {
|
||||
title: "Pair device",
|
||||
slash: { name: "pair", aliases: ["web"] },
|
||||
run: () => {
|
||||
dialog.replace(() => <DialogPair credentials={props.pair} />)
|
||||
dialog.replace(() => <DialogPair />)
|
||||
},
|
||||
category: "System",
|
||||
},
|
||||
|
||||
@@ -8,95 +8,65 @@ import { useDialog } from "../ui/dialog"
|
||||
import { Link } from "../ui/link"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export type DialogPairCredentials = {
|
||||
readonly username: string
|
||||
readonly password: string
|
||||
}
|
||||
|
||||
export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
export function DialogPair() {
|
||||
const client = useClient()
|
||||
const dialog = useDialog()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = useTheme().surface("dialog")
|
||||
const [loadError, setLoadError] = createSignal<unknown>()
|
||||
const [showPassword, setShowPassword] = createSignal(false)
|
||||
const [passwordHover, setPasswordHover] = createSignal(false)
|
||||
|
||||
dialog.setSize("large")
|
||||
dialog.setCentered(true)
|
||||
|
||||
const [server] = createResource(() =>
|
||||
client.api.server.info().catch((error) => {
|
||||
setLoadError(error)
|
||||
return undefined
|
||||
}),
|
||||
const [info] = createResource(() =>
|
||||
Promise.all([client.api.server.info(), client.api.server.pair()])
|
||||
.then(([server, pairing]) => {
|
||||
const link = (url: string) => new URL(`/auth/connect/${pairing.code}`, url).href
|
||||
const local = server.urls[0] ? new URL(server.urls[0]) : undefined
|
||||
if (local) local.hostname = "localhost"
|
||||
return {
|
||||
links: server.urls.map(link),
|
||||
localhost: local ? link(local.href) : undefined,
|
||||
minutes: Math.round(pairing.expires_in / 60),
|
||||
loopback: server.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname)),
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setLoadError(error)
|
||||
return undefined
|
||||
}),
|
||||
)
|
||||
const info = createMemo(() => {
|
||||
const current = server()
|
||||
if (!current) return
|
||||
return {
|
||||
urls: current.urls,
|
||||
username: props.credentials?.username ?? "opencode",
|
||||
password: props.credentials?.password ?? "",
|
||||
}
|
||||
})
|
||||
const localhost = createMemo(() => {
|
||||
const value = info()?.urls[0]
|
||||
if (!value) return ""
|
||||
const url = new URL(value)
|
||||
url.hostname = "localhost"
|
||||
return url.toString().replace(/\/$/, "")
|
||||
})
|
||||
const horizontal = createMemo(() => dimensions().width >= 96)
|
||||
const content = () => {
|
||||
const value = info()
|
||||
if (!value) return
|
||||
const href = (input: string) => {
|
||||
const url = new URL(input)
|
||||
url.username = encodeURIComponent(value.username)
|
||||
url.password = encodeURIComponent(value.password)
|
||||
return url.toString()
|
||||
}
|
||||
return (
|
||||
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
|
||||
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text.muted}>This device</text>
|
||||
<Show when={localhost()}>
|
||||
{(url) => (
|
||||
<Link href={href(url())} fg={theme.text.base}>
|
||||
<text fg={theme.text.muted} wrapMode="word">
|
||||
Open a link to connect. Links work once and expire in {value.minutes} minutes.
|
||||
</text>
|
||||
<Show when={value.localhost}>
|
||||
{(url) => (
|
||||
<box>
|
||||
<text fg={theme.text.muted}>This device</text>
|
||||
<Link href={url()} fg={theme.text.base}>
|
||||
{url()}
|
||||
</Link>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
<box>
|
||||
<text fg={theme.text.muted}>URLs</text>
|
||||
<For each={value.urls}>
|
||||
<text fg={theme.text.muted}>Links</text>
|
||||
<For each={value.links}>
|
||||
{(url) => (
|
||||
<Link href={href(url)} fg={theme.text.base}>
|
||||
<Link href={url} fg={theme.text.base}>
|
||||
{url}
|
||||
</Link>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.text.muted}>Username</text>
|
||||
<text fg={theme.text.base}>{value.username}</text>
|
||||
</box>
|
||||
<box>
|
||||
<text fg={theme.text.muted}>Password</text>
|
||||
<text
|
||||
fg={passwordHover() ? theme.text.base : theme.text.muted}
|
||||
wrapMode="word"
|
||||
onMouseOver={() => setPasswordHover(true)}
|
||||
onMouseOut={() => setPasswordHover(false)}
|
||||
onMouseUp={() => setShowPassword((current) => !current)}
|
||||
>
|
||||
{showPassword() ? value.password : "************"}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
|
||||
<Show when={value.loopback}>
|
||||
<text fg={theme.text.muted} wrapMode="word">
|
||||
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
|
||||
</text>
|
||||
@@ -108,7 +78,9 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
|
||||
flexShrink={0}
|
||||
alignItems={horizontal() ? "flex-end" : "center"}
|
||||
>
|
||||
<text fg={theme.text.base}>{renderUnicodeCompact(JSON.stringify(value), { border: 1 })}</text>
|
||||
<Show when={value.links[0]}>
|
||||
{(url) => <text fg={theme.text.base}>{renderUnicodeCompact(url(), { border: 1 })}</text>}
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -1,31 +1,53 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
|
||||
import {
|
||||
BoxRenderable,
|
||||
DiffRenderable,
|
||||
LineNumberRenderable,
|
||||
type ColorInput,
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { createMemo, For, Show, splitProps } from "solid-js"
|
||||
import { splitPatchHunks } from "../util/diff"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, For, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { splitAddedPatch, splitPatchHunks, type AddedPatchChunk } from "../util/diff"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export interface PatchDiffRef {
|
||||
readonly hunks: () => readonly DiffRenderable[]
|
||||
readonly hunks: () => readonly (DiffRenderable | BoxRenderable)[]
|
||||
}
|
||||
|
||||
const VIRTUAL_CHUNK_LINES = 128
|
||||
|
||||
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref"> & {
|
||||
diff: string
|
||||
hunkFg: ColorInput
|
||||
lineNumberBg: ColorInput
|
||||
ref?: (value: PatchDiffRef) => void
|
||||
virtualScroll?: () => ScrollBoxRenderable | undefined
|
||||
viewportWidth?: number
|
||||
}
|
||||
|
||||
export function PatchDiff(props: Props) {
|
||||
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
|
||||
const [local, diffProps] = splitProps(props, [
|
||||
"diff",
|
||||
"hunkFg",
|
||||
"lineNumberBg",
|
||||
"ref",
|
||||
"virtualScroll",
|
||||
"viewportWidth",
|
||||
])
|
||||
const hunks = createMemo(() => splitPatchHunks(local.diff))
|
||||
const chunks = createMemo(() => local.virtualScroll && splitAddedPatch(local.diff, VIRTUAL_CHUNK_LINES))
|
||||
const nodes = new Map<number, DiffRenderable>()
|
||||
let virtualRoot: BoxRenderable | undefined
|
||||
local.ref?.({
|
||||
hunks: () =>
|
||||
[...nodes.entries()]
|
||||
hunks: () => {
|
||||
if (chunks()) return virtualRoot && !virtualRoot.isDestroyed ? [virtualRoot] : []
|
||||
return [...nodes.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, node]) => node)
|
||||
.filter((node) => !node.isDestroyed),
|
||||
.filter((node) => !node.isDestroyed)
|
||||
},
|
||||
})
|
||||
const syncGutters = (attempt = 0) => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -55,29 +77,130 @@ export function PatchDiff(props: Props) {
|
||||
}
|
||||
const register = (index: number, node: DiffRenderable) => {
|
||||
nodes.set(index, node)
|
||||
onCleanup(() => nodes.delete(index))
|
||||
syncGutters()
|
||||
}
|
||||
|
||||
return (
|
||||
<For each={hunks()}>
|
||||
{(hunk, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
|
||||
<text fg={local.hunkFg} bg={local.lineNumberBg}>
|
||||
{` ${hunk.header ?? ""}`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<diff
|
||||
{...diffProps}
|
||||
ref={(node: DiffRenderable) => register(index(), node)}
|
||||
diff={hunk.patch}
|
||||
minHeight={hunk.rows}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
/>
|
||||
</>
|
||||
<Show
|
||||
when={chunks()}
|
||||
fallback={
|
||||
<For each={hunks()}>
|
||||
{(hunk, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
|
||||
<text fg={local.hunkFg} bg={local.lineNumberBg}>
|
||||
{` ${hunk.header ?? ""}`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<diff
|
||||
{...diffProps}
|
||||
ref={(node: DiffRenderable) => register(index(), node)}
|
||||
diff={hunk.patch}
|
||||
minHeight={hunk.rows}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
{(items) => (
|
||||
<VirtualAddedPatch
|
||||
chunks={items()}
|
||||
width={local.viewportWidth ?? 80}
|
||||
scroll={local.virtualScroll!}
|
||||
diffProps={diffProps}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
register={register}
|
||||
registerRoot={(root) => (virtualRoot = root)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function VirtualAddedPatch(props: {
|
||||
chunks: readonly AddedPatchChunk[]
|
||||
width: number
|
||||
scroll: () => ScrollBoxRenderable | undefined
|
||||
diffProps: Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref">
|
||||
lineNumberBg: ColorInput
|
||||
register: (index: number, node: DiffRenderable) => void
|
||||
registerRoot: (root: BoxRenderable) => void
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
const [visible, setVisible] = createSignal(0)
|
||||
const [measured, setMeasured] = createSignal<ReadonlyMap<number, number>>(new Map())
|
||||
createEffect(() => {
|
||||
props.width
|
||||
props.chunks
|
||||
setMeasured(new Map())
|
||||
})
|
||||
// Offscreen chunks need heights for scroll jumps before OpenTUI has measured them.
|
||||
// Replace those estimates with actual rendered heights as chunks enter the viewport.
|
||||
const estimates = createMemo(() => {
|
||||
const codeWidth = Math.max(
|
||||
1,
|
||||
props.width - String(props.chunks.reduce((count, chunk) => count + chunk.rows, 0)).length - 5,
|
||||
)
|
||||
return props.chunks.map((chunk) =>
|
||||
chunk.lines.reduce((height, line) => height + Math.max(1, Math.ceil(stringWidth(line.slice(1)) / codeWidth)), 0),
|
||||
)
|
||||
})
|
||||
const heights = createMemo(() => estimates().map((estimate, index) => measured().get(index) ?? estimate))
|
||||
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
ref={(root: BoxRenderable) => {
|
||||
props.registerRoot(root)
|
||||
root.onLifecyclePass = () => {
|
||||
const scroll = props.scroll()
|
||||
if (!scroll) return
|
||||
// ScrollBox's scroll position is not a Solid signal; observe it during the render pass.
|
||||
const offset = root.y - scroll.content.y
|
||||
const top = scroll.scrollTop - offset
|
||||
const sizes = heights()
|
||||
if (top + scroll.viewport.height < 0 || top > sizes.reduce((sum, height) => sum + height, 0)) {
|
||||
setVisible(-1)
|
||||
return
|
||||
}
|
||||
let position = 0
|
||||
const index = sizes.findIndex((height) => (position += height) > top)
|
||||
setVisible(index < 0 ? sizes.length - 1 : index)
|
||||
}
|
||||
renderer.registerLifecyclePass(root)
|
||||
onCleanup(() => renderer.unregisterLifecyclePass(root))
|
||||
}}
|
||||
>
|
||||
<For each={props.chunks}>
|
||||
{(chunk, index) => (
|
||||
<Show
|
||||
when={visible() >= 0 && Math.abs(index() - visible()) <= 2}
|
||||
fallback={<box height={heights()[index()]} />}
|
||||
>
|
||||
<diff
|
||||
{...props.diffProps}
|
||||
ref={(node: DiffRenderable) => {
|
||||
props.register(index(), node)
|
||||
node.onSizeChange = () => {
|
||||
if (node.height <= 0 || measured().get(index()) === node.height) return
|
||||
const scroll = props.scroll()
|
||||
const atEnd = scroll && scroll.scrollTop >= scroll.scrollHeight - scroll.viewport.height - 1
|
||||
setMeasured((known) => new Map(known).set(index(), node.height))
|
||||
// Keep G pinned to the end when a newly mounted chunk changes total height.
|
||||
if (atEnd) requestAnimationFrame(() => scroll.scrollTo(Infinity))
|
||||
}
|
||||
}}
|
||||
diff={chunk.patch}
|
||||
lineNumberBg={props.lineNumberBg}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1024,6 +1024,12 @@ export function DiffViewerContent(props: {
|
||||
onCleanup(() => patchDiffByFileIndex.delete(entry.fileIndex))
|
||||
}}
|
||||
diff={patch()}
|
||||
virtualScroll={
|
||||
entry.file.status === "added" && entry.file.additions > 1000
|
||||
? () => scroll
|
||||
: undefined
|
||||
}
|
||||
viewportWidth={patchPaneWidth()}
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
view={entry.file.status === "modified" ? view() : "unified"}
|
||||
filetype={filetype(entry.file.file)}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useAttention } from "../context/attention"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { useOptionalPanel } from "../context/panel"
|
||||
import { useLocal } from "../context/local"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
|
||||
export type Dispose = () => Promise<void>
|
||||
@@ -70,6 +71,7 @@ export function usePluginHost() {
|
||||
storage: useStorage(),
|
||||
sessionTabs: useSessionTabs(),
|
||||
panel: useOptionalPanel(),
|
||||
local: useLocal(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +251,22 @@ export function createPluginContext(input: {
|
||||
return true
|
||||
},
|
||||
},
|
||||
model: {
|
||||
current() {
|
||||
const selection = host.local.model.selection()
|
||||
if (!selection) return
|
||||
return { providerID: selection.providerID, modelID: selection.modelID, variant: selection.variant }
|
||||
},
|
||||
variant: {
|
||||
list: () => host.local.model.variant.list(),
|
||||
set(variant) {
|
||||
if (!host.local.model.selection()) return false
|
||||
if (variant !== undefined && !host.local.model.variant.list().includes(variant)) return false
|
||||
host.local.model.variant.set(variant)
|
||||
return true
|
||||
},
|
||||
},
|
||||
},
|
||||
slot(value: SlotClaim) {
|
||||
// Keys are counter-suffixed so one plugin may claim several places;
|
||||
// order within the plugin is registration order.
|
||||
|
||||
@@ -4,11 +4,56 @@ import { useTheme } from "../../context/theme"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { abbreviateHome } from "../../util/path-format"
|
||||
import { SessionQuestion } from "./permission"
|
||||
import { usePromptMove } from "../../component/prompt/move"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useClient } from "../../context/client"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { DialogWorkspaces, type WorkspaceSelection } from "../../component/dialog-workspaces"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
export function SessionLocationMissing(props: { directory: string; projectID: string; sessionID: string }) {
|
||||
const move = usePromptMove({ projectID: () => props.projectID, sessionID: () => props.sessionID })
|
||||
return <SessionLocationUnavailable directory={props.directory} onMove={move.open} />
|
||||
const dialog = useDialog()
|
||||
const client = useClient()
|
||||
const toast = useToast()
|
||||
const data = useData()
|
||||
|
||||
function open() {
|
||||
dialog.replace(() => (
|
||||
<DialogWorkspaces
|
||||
projectID={props.projectID}
|
||||
location={{ directory: props.directory }}
|
||||
current={{
|
||||
type: "directory",
|
||||
directory: props.directory,
|
||||
subdirectory: !!data.session.get(props.sessionID)?.subpath,
|
||||
}}
|
||||
onSelect={(selection) => void select(selection)}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
async function select(selection: WorkspaceSelection) {
|
||||
dialog.clear()
|
||||
const directory =
|
||||
selection.type === "directory"
|
||||
? selection.directory
|
||||
: await client.api.worktree
|
||||
.create({ projectID: props.projectID, name: selection.name })
|
||||
.then((result) => {
|
||||
if (!result.directory) throw new Error("No worktree directory returned")
|
||||
return result.directory
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.show({ title: "Creating workspace failed", message: errorMessage(error), variant: "error" })
|
||||
return undefined
|
||||
})
|
||||
if (!directory) return
|
||||
await client.api.session.move({ sessionID: props.sessionID, directory }).catch((error) => {
|
||||
toast.show({ title: "Failed to move session", message: errorMessage(error), variant: "error" })
|
||||
})
|
||||
}
|
||||
|
||||
return <SessionLocationUnavailable directory={props.directory} onMove={open} />
|
||||
}
|
||||
|
||||
export function SessionLocationUnavailable(props: { directory: string; onMove: () => void }) {
|
||||
|
||||
@@ -4,6 +4,35 @@ export interface PatchHunk {
|
||||
readonly rows?: number
|
||||
}
|
||||
|
||||
export interface AddedPatchChunk {
|
||||
readonly patch: string
|
||||
readonly lines: readonly string[]
|
||||
readonly rows: number
|
||||
}
|
||||
|
||||
/** Only a complete, single-hunk new-file patch can be split without changing diff semantics. */
|
||||
export function splitAddedPatch(patch: string, size: number): AddedPatchChunk[] | undefined {
|
||||
const header = /^@@ -0,0 \+1,(\d+) @@[^\n]*\n/m.exec(patch)
|
||||
if (!header) return
|
||||
const count = Number(header[1])
|
||||
const lines = patch
|
||||
.slice(header.index + header[0].length)
|
||||
.replace(/\n$/, "")
|
||||
.split("\n")
|
||||
const marker = lines.at(-1)?.startsWith("\\ No newline at end of file") ? lines.pop() : undefined
|
||||
if (lines.length !== count || lines.some((line) => !line.startsWith("+"))) return
|
||||
const prefix = patch.slice(0, header.index)
|
||||
return Array.from({ length: Math.ceil(count / size) }, (_, index) => {
|
||||
const start = index * size
|
||||
const slice = lines.slice(start, start + size)
|
||||
return {
|
||||
patch: `${prefix}@@ -0,0 +${start + 1},${slice.length} @@\n${slice.join("\n")}${marker && start + size >= count ? `\n${marker}` : ""}`,
|
||||
lines: slice,
|
||||
rows: slice.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function splitPatchHunks(patch: string): PatchHunk[] {
|
||||
const starts = [...patch.matchAll(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@.*$/gm)].map((match) => match.index)
|
||||
if (starts.length <= 1) return [{ patch }]
|
||||
|
||||
@@ -2058,6 +2058,98 @@ const manyDiffs = Array.from({ length: 40 }, (_, index) => ({
|
||||
file: `file${String(index).padStart(2, "0")}.txt`,
|
||||
}))
|
||||
|
||||
test.each([80, 160])("virtualizes a large added file at %i columns without losing its end", async (width) => {
|
||||
const lines = [
|
||||
"+{",
|
||||
...Array.from(
|
||||
{ length: 2500 },
|
||||
(_, index) =>
|
||||
`+ "row-${String(index).padStart(4, "0")}": "${"value".repeat(index === 777 ? 2000 : index % 7 === 0 ? 24 : 1)}"${index === 2499 ? "" : ","}`,
|
||||
),
|
||||
"+}",
|
||||
]
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "snapshot.json",
|
||||
status: "added",
|
||||
additions: lines.length,
|
||||
deletions: 0,
|
||||
patch: `diff --git a/snapshot.json b/snapshot.json\nnew file mode 100644\n--- /dev/null\n+++ b/snapshot.json\n@@ -0,0 +1,${lines.length} @@\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
{ width, height: 24 },
|
||||
)
|
||||
try {
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-0000")
|
||||
expect(
|
||||
findDiffs(viewer.app.renderer.root).reduce((total, node) => total + node.diff.split("\n").length, 0),
|
||||
).toBeLessThan(900)
|
||||
viewer.commands.get("diff.last")!.run()
|
||||
await viewer.app.flush()
|
||||
if (!viewer.app.captureCharFrame().includes("row-2499")) {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("row-2499"))
|
||||
}
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-2499")
|
||||
expect(
|
||||
findDiffs(viewer.app.renderer.root).reduce((total, node) => total + node.diff.split("\n").length, 0),
|
||||
).toBeLessThan(900)
|
||||
viewer.commands.get("diff.first")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-0000")
|
||||
viewer.app.resize(width === 80 ? 160 : 80, 20)
|
||||
await viewer.app.flush()
|
||||
viewer.commands.get("diff.last")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-2499")
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("file navigation and review still work after a virtualized patch", async () => {
|
||||
const additions = Array.from({ length: 2200 }, (_, index) => `+added line ${index}`)
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "a-large.txt",
|
||||
status: "added",
|
||||
additions: additions.length,
|
||||
deletions: 0,
|
||||
patch: `--- /dev/null\n+++ b/a-large.txt\n@@ -0,0 +1,${additions.length} @@\n${additions.join("\n")}`,
|
||||
},
|
||||
{ ...hunkDiff[0], file: "b-small.txt" },
|
||||
],
|
||||
{ width: 160, height: 24 },
|
||||
)
|
||||
try {
|
||||
const scroll = findScrollBox(viewer.app.renderer.root)!
|
||||
scroll.scrollTo(900)
|
||||
await viewer.app.flush()
|
||||
viewer.commands.get("diff.previous_hunk")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("added line 0")
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("b-small.txt")
|
||||
viewer.commands.get("diff.next_file")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("b-small.txt")
|
||||
expect(viewer.app.captureCharFrame()).toContain("const first")
|
||||
viewer.commands.get("diff.previous_file")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("a-large.txt")
|
||||
viewer.commands.get("diff.mark_reviewed")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("added line 0")
|
||||
viewer.commands.get("diff.mark_reviewed")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("added line 0")
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function findScrollBox(root: Renderable, patches = true): ScrollBoxRenderable | undefined {
|
||||
const node = root.findDescendantById(patches ? "diff-patches" : "diff-files")
|
||||
return node instanceof ScrollBoxRenderable ? node : undefined
|
||||
|
||||
@@ -12,6 +12,7 @@ import { RouteProvider, useRoute } from "../../../src/context/route"
|
||||
import { ThemeProvider } from "../../../src/context/theme"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider, useToast } from "../../../src/ui/toast"
|
||||
import { SessionLocationMissing } from "../../../src/routes/session/location-missing"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
@@ -175,6 +176,68 @@ test.each([false, true])("Ctrl+M moves only an existing session (home=%s)", asyn
|
||||
}
|
||||
})
|
||||
|
||||
test("choosing a directory recovers the session when its location is unavailable", async () => {
|
||||
const fixture = await renderMove({ directory: clone, unavailable: "location", showMissingLocation: true })
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Session location unavailable"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
|
||||
await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
await fixture.app.mockInput.typeText("linked")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(main))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.moves.length === 1)
|
||||
|
||||
expect(fixture.moves).toEqual([{ directory: linked }])
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_clone" })
|
||||
expect(fixture.requests).toEqual([])
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("creating a worktree recovers the session without reading its removed location", async () => {
|
||||
const fixture = await renderMove({ directory: clone, unavailable: "location", showMissingLocation: true })
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Session location unavailable"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
|
||||
fixture.app.mockInput.pressKey("a", { ctrl: true })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Name worktree"))
|
||||
await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
await fixture.app.mockInput.typeText("fresh")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.moves.length === 1)
|
||||
|
||||
expect(fixture.requests).toEqual([{ payload: { projectID: "proj_test", name: "fresh" }, directory: null }])
|
||||
expect(fixture.moves).toEqual([{ directory: created }])
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_clone" })
|
||||
expect(fixture.reads.locations).not.toContain(clone)
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("failed recovery does not navigate away from the session", async () => {
|
||||
const fixture = await renderMove({ directory: clone, unavailable: "location", showMissingLocation: true, moveFails: true })
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Session location unavailable"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Worktrees") && frame.includes(linked))
|
||||
await fixture.app.waitFor(() => fixture.app.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
await fixture.app.mockInput.typeText("linked")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes(linked) && !frame.includes(main))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.toast.currentToast !== null)
|
||||
|
||||
expect(fixture.moves).toEqual([{ directory: linked }])
|
||||
expect(fixture.route.data).toEqual({ type: "session", sessionID: "ses_clone" })
|
||||
expect(fixture.toast.currentToast).toMatchObject({ title: "Failed to move session", variant: "error" })
|
||||
} finally {
|
||||
fixture.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ name: "session", unavailable: "session" as const },
|
||||
{ name: "location", unavailable: "location" as const },
|
||||
@@ -223,6 +286,8 @@ async function renderMove(input: {
|
||||
launch?: string
|
||||
launchProjectID?: string
|
||||
unavailable?: "session" | "location"
|
||||
showMissingLocation?: boolean
|
||||
moveFails?: boolean
|
||||
}) {
|
||||
const launch = input.launch ?? (input.home ? input.directory : main)
|
||||
const requests: unknown[] = []
|
||||
@@ -294,6 +359,7 @@ async function renderMove(input: {
|
||||
}
|
||||
if (url.pathname === "/api/session/ses_clone/move") {
|
||||
moves.push(await request.json())
|
||||
if (input.moveFails) return json({ message: "Destination unavailable" }, { status: 503 })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
@@ -313,7 +379,9 @@ async function renderMove(input: {
|
||||
projectID: () => (input.home ? data.location.info()?.project.id : "proj_test"),
|
||||
sessionID: () => (input.home ? undefined : "ses_clone"),
|
||||
})
|
||||
return null
|
||||
return input.showMissingLocation ? (
|
||||
<SessionLocationMissing directory={input.directory} projectID="proj_test" sessionID="ses_clone" />
|
||||
) : null
|
||||
}
|
||||
|
||||
const app = await testRender(
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { createPluginContext, type Registry, type usePluginHost } from "../src/plugin/api"
|
||||
import { model, renderLocal } from "./fixture/local"
|
||||
|
||||
// Eagerly read host services need a shape; model access goes through the real LocalProvider.
|
||||
function pluginModel(local: ReturnType<typeof usePluginHost>["local"]) {
|
||||
const host = {
|
||||
app: {},
|
||||
client: {},
|
||||
keymap: {},
|
||||
shortcuts: {},
|
||||
keymapState: {},
|
||||
sessionTabs: {},
|
||||
local,
|
||||
} as unknown as ReturnType<typeof usePluginHost>
|
||||
const registry: Registry = { has: () => false, set() {}, remove() {}, active: () => true }
|
||||
return createPluginContext({ host, id: "test", options: undefined, owned: [], registry }).ui.model
|
||||
}
|
||||
|
||||
test("plugins read and select variants of the selected model", async () => {
|
||||
await using setup = await renderLocal({ models: [model("first", ["low", "high"])] })
|
||||
const selected = pluginModel(setup.local)
|
||||
|
||||
expect(selected.current()).toEqual({ providerID: "provider", modelID: "first", variant: undefined })
|
||||
expect(selected.variant.list()).toEqual(["low", "high"])
|
||||
|
||||
expect(selected.variant.set("high")).toBe(true)
|
||||
expect(selected.current()?.variant).toBe("high")
|
||||
expect(setup.local.model.variant.current()).toBe("high")
|
||||
|
||||
expect(selected.variant.set(undefined)).toBe(true)
|
||||
expect(selected.current()?.variant).toBeUndefined()
|
||||
})
|
||||
|
||||
test("plugins cannot select unavailable variants or variants without a model", async () => {
|
||||
await using setup = await renderLocal({ models: [model("first", ["low", "high"])] })
|
||||
const selected = pluginModel(setup.local)
|
||||
expect(selected.variant.set("max")).toBe(false)
|
||||
expect(selected.current()?.variant).toBeUndefined()
|
||||
|
||||
await using empty = await renderLocal({ models: [] })
|
||||
const none = pluginModel(empty.local)
|
||||
expect(none.current()).toBeUndefined()
|
||||
expect(none.variant.list()).toEqual([])
|
||||
expect(none.variant.set(undefined)).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { splitAddedPatch } from "../../src/util/diff"
|
||||
|
||||
test("splits a complete new-file patch into independently numbered chunks", () => {
|
||||
const patch = `diff --git a/new.txt b/new.txt
|
||||
new file mode 100644
|
||||
--- /dev/null
|
||||
+++ b/new.txt
|
||||
@@ -0,0 +1,5 @@
|
||||
+one
|
||||
+++value beginning with plus signs
|
||||
+three
|
||||
+four
|
||||
+five`
|
||||
const chunks = splitAddedPatch(patch, 2)!
|
||||
expect(chunks.map((chunk) => chunk.rows)).toEqual([2, 2, 1])
|
||||
expect(chunks.map((chunk) => chunk.patch.match(/@@ -0,0 \+(\d+),(\d+) @@/)?.slice(1))).toEqual([
|
||||
["1", "2"],
|
||||
["3", "2"],
|
||||
["5", "1"],
|
||||
])
|
||||
expect(chunks.flatMap((chunk) => chunk.lines)).toEqual([
|
||||
"+one",
|
||||
"+++value beginning with plus signs",
|
||||
"+three",
|
||||
"+four",
|
||||
"+five",
|
||||
])
|
||||
expect(chunks.every((chunk) => chunk.patch.startsWith("diff --git a/new.txt b/new.txt"))).toBe(true)
|
||||
})
|
||||
|
||||
test("retains a missing-final-newline marker only on the last chunk", () => {
|
||||
const patch = `--- /dev/null\n+++ b/new.txt\n@@ -0,0 +1,3 @@\n+one\n+two\n+three\n\\ No newline at end of file\n`
|
||||
const chunks = splitAddedPatch(patch, 2)!
|
||||
expect(chunks).toHaveLength(2)
|
||||
expect(chunks[0].patch).not.toContain("No newline")
|
||||
expect(chunks[1].patch).toContain("+three\n\\ No newline at end of file")
|
||||
})
|
||||
|
||||
test("does not split partial or mixed patches", () => {
|
||||
expect(splitAddedPatch("@@ -1 +1 @@\n-before\n+after", 2)).toBeUndefined()
|
||||
expect(splitAddedPatch("@@ -0,0 +1,3 @@\n+one\n+two", 2)).toBeUndefined()
|
||||
expect(splitAddedPatch("@@ -0,0 +1,2 @@\n+one\n two", 2)).toBeUndefined()
|
||||
})
|
||||
@@ -389,6 +389,39 @@ if (context.ui.tabs.enabled()) {
|
||||
}
|
||||
```
|
||||
|
||||
## Model
|
||||
|
||||
Read the prompt's selected model and change its variant. `current()` and `variant.list()` are reactive when read in a
|
||||
Solid computation. A `variant` of `undefined` is the model default.
|
||||
|
||||
```ts
|
||||
const selected = context.ui.model.current()
|
||||
const variants = context.ui.model.variant.list()
|
||||
context.ui.model.variant.set("high")
|
||||
context.ui.model.variant.set(undefined)
|
||||
```
|
||||
|
||||
`variant.set` returns `false` when no model is selected or the model has no such variant. Bind it to a command to
|
||||
replace the built-in variant cycle, for example to skip the model default:
|
||||
|
||||
```ts
|
||||
context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
id: "acme.variant.cycle",
|
||||
title: "Cycle variants",
|
||||
bind: "tab",
|
||||
run() {
|
||||
const variants = context.ui.model.variant.list()
|
||||
if (variants.length === 0) return
|
||||
const index = variants.indexOf(context.ui.model.current()?.variant ?? "")
|
||||
context.ui.model.variant.set(variants[(index + 1) % variants.length])
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
```
|
||||
|
||||
## Slots
|
||||
|
||||
Slots insert or replace JSX at `app`, `home.footer`, `home.footer.status`, `prompt.footer`, `prompt.footer.status`,
|
||||
|
||||
@@ -362,15 +362,14 @@ $ opencode serve --help
|
||||
|
||||
## pair
|
||||
|
||||
Shows server pairing information, including URLs, credentials, and a QR code. The
|
||||
QR code contains a direct link to the advertised server, with credentials in the
|
||||
URL fragment.
|
||||
Prints one-time links, plus a QR code of the first one, that sign a browser or
|
||||
app in to the server. Links expire after 5 minutes and work once.
|
||||
|
||||
```bash
|
||||
$ opencode pair
|
||||
```
|
||||
|
||||
Advertise an external URL in the QR code and link.
|
||||
Use an external URL in the links.
|
||||
|
||||
```bash
|
||||
$ opencode pair --url https://dev.example.com
|
||||
|
||||
@@ -11,20 +11,26 @@ TUI. It's available by default and password protected.
|
||||
```bash
|
||||
$ opencode pair
|
||||
|
||||
URLs http://127.0.0.1:49374
|
||||
Username opencode
|
||||
Password ********
|
||||
Open a link to connect. Links work once and expire in 5 minutes.
|
||||
|
||||
Scan to pair
|
||||
http://127.0.0.1:49374/auth/connect/...
|
||||
|
||||
█▀▀▀▀▀█ ...
|
||||
|
||||
Link http://127.0.0.1:49374/connect#...
|
||||
```
|
||||
|
||||
The QR code and link open the advertised server directly. Connection credentials
|
||||
stay in the URL fragment and are saved by the `/connect` page before it starts
|
||||
authenticated requests.
|
||||
Opening a link in a browser signs it in with a session cookie and loads the web
|
||||
ui. Scanning the QR code from the OpenCode app, or pasting the link into its
|
||||
server address field, connects the app the same way. Sessions last 30 days;
|
||||
changing the server password signs every session out.
|
||||
|
||||
### Over SSH
|
||||
|
||||
When the server listens only on localhost, forward its port from your machine
|
||||
and open the printed link locally:
|
||||
|
||||
```bash
|
||||
$ ssh -L 49374:127.0.0.1:49374 my-server
|
||||
```
|
||||
|
||||
By default the server runs on port 49374 and listens only on localhost. You can
|
||||
change this config with the `opencode service` command.
|
||||
|
||||
Reference in New Issue
Block a user