mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-25 18:17:35 +00:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beab7df087 | ||
|
|
14fc63ba9e | ||
|
|
c34ffa117e | ||
|
|
beeb14e910 | ||
|
|
aae42e2e75 | ||
|
|
cc9011c1ae | ||
|
|
6cd938e1e9 | ||
|
|
7de6b3fc15 | ||
|
|
c1c9a13993 | ||
|
|
917d904f18 | ||
|
|
ee5b67eb84 | ||
|
|
048a47e89e | ||
|
|
5335347e80 | ||
|
|
16b18dff13 | ||
|
|
61c2349cef | ||
|
|
684721efb8 | ||
|
|
962c14a49c | ||
|
|
85b98e7da4 | ||
|
|
8061220b08 | ||
|
|
b02cc35f13 | ||
|
|
e23d89c9a9 | ||
|
|
e8b3e19e85 | ||
|
|
5256f30957 | ||
|
|
61ecf404b9 | ||
|
|
92d2b1700f | ||
|
|
56262121ee | ||
|
|
e3b588e7d2 | ||
|
|
1de648cb13 | ||
|
|
03be7f385b | ||
|
|
8118690839 | ||
|
|
a16eedfed7 | ||
|
|
e796f2f9a5 | ||
|
|
20610e6645 | ||
|
|
7f245b0968 | ||
|
|
7013e925f5 | ||
|
|
499c2feaa3 | ||
|
|
03af821aa5 | ||
|
|
c903774556 | ||
|
|
14aaf91e65 | ||
|
|
c832432d89 | ||
|
|
1d431a80df |
@@ -597,8 +597,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"@opentui/core": ">=0.5.12",
|
||||
"@opentui/solid": ">=0.5.12",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1114,9 +1114,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@opentui/core": "0.5.12",
|
||||
"@opentui/keymap": "0.5.12",
|
||||
"@opentui/solid": "0.5.12",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2252,27 +2252,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.12", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.12", "@opentui/core-darwin-x64": "0.5.12", "@opentui/core-linux-arm64": "0.5.12", "@opentui/core-linux-arm64-musl": "0.5.12", "@opentui/core-linux-x64": "0.5.12", "@opentui/core-linux-x64-musl": "0.5.12", "@opentui/core-win32-arm64": "0.5.12", "@opentui/core-win32-x64": "0.5.12" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ZXBE5gmvdovmV8zJQrOQf6E44v1tJRDEgrM2MYhEglzgXZ+smIUp95O8zeRYGsuIzQIiMPMgQqKtTJuzvAb7BQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YdVnP0tAyerBNl0mIcmQEOotPeZzW1VnSXKBl5cyZ5e6nDd2Y+ui/8eRPpn1oqcamf1NCnzS4ohMgejOvna8Zg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-uRrQJdHmLUSj3PV23QPi3WSimYTTxcXnVouxF6U4xMXlOv4N3SxnHfVwMRQkPqbGOfvVWHeLE6FdK4C+ubU0sQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-XeKhuIaEtgipvuPHbl4qPOBj+Ut+2zObmsxMVM1jDcjz/FatG9PGeGQPx1G1SnvH2AgpT4K+eCu7DUF0+yIqoQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ2sNMw1d/r1SLPjUbOP9LKscKz1CQjID8adTL6gG8Lrrq+mYcIUxutyB+P/eG0J/7oRZLPR6OMt7dUOap6RTg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-eZiCjEzwbb6qClPPfk32Nha9xmr9obt69Xj0+9SKsXxWLBKkjQEGOMRoh/R9ObaQF4aq8If1xV3VEY0sD9W9vg=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-WWW0hVBoSYZ3D6AgZ4u2Y5/u/IyIq2pDb+4yI3WgJ70Wyt6ofHy+6kRGRgbXFn1p+rPInAHjCXD2v6C7iEKSrA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-aLbm6870Ybls6CYL4zMOCImTBPLZHZMUXJFGqMI44lIWxitkAtT6zg5lYA4oRqFRzzryDclxr29+hDgT3p3Blw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-KTwtwpfd2zF9opVh3SyRJYDd1o3Xv4XL8OZb8Zi+CqWUel6Y2IDCiVivCv8fGJt3J7wOIXXtuZI9ZUkLyKJCiQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.12", "", { "dependencies": { "@opentui/core": "0.5.12" }, "peerDependencies": { "@opentui/react": "0.5.12", "@opentui/solid": "0.5.12", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yWPvJjRhJTRoRSUucQq9Ua8ZW7n/2YQ/j6JxWq5Qekm4WuFiTplEkebR/Aj2/xA8tX68NOE5qv1LrY0Jk3NLNQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.12", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.12", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-hAiVlVMtT7AkHGblKwcW1YAuXtxkSy1XSf/RRc4j3IlG3mTNX0bhJdnGOo3Xw14EqeZMp41Mcp5WzHAzMm/DzA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-+Clo0VPDdruHSoBNvV/wKAM8iR6HJPtB00oa8yl9ujU=",
|
||||
"aarch64-linux": "sha256-4wU5v36GTXjwyt5ls4FH+5G43Ujd+dKVSJR21w3lhbA=",
|
||||
"aarch64-darwin": "sha256-pThjoD6baddQ6biy7k1ByXwGwLAeWe/+w0tcYmt1uWs=",
|
||||
"x86_64-darwin": "sha256-bCBl63CqBiqilb+YdaOLBYYZx/yf47c1aqgDOkgdegg="
|
||||
"x86_64-linux": "sha256-aQQQhaUlAhpfqzH0vNi0IJ1cg7FQHIKYzxeq5d8PZoU=",
|
||||
"aarch64-linux": "sha256-r9aDFu3UYmudmmYPhzCrpFvQlaejXc8V1IzLtG3jZPc=",
|
||||
"aarch64-darwin": "sha256-B0m41LelD7d61vPHGIZZSO/cU7gbjHDJHt6oxNRRM8Q=",
|
||||
"x86_64-darwin": "sha256-9TWJsyI3Y6BMomtGSgqA1th9LpxpqP4F5Tl/GyexVYw="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -52,9 +52,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@opentui/core": "0.5.12",
|
||||
"@opentui/keymap": "0.5.12",
|
||||
"@opentui/solid": "0.5.12",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## Conventions
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path. Two ways to construct the same thing is one too many.
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
|
||||
|
||||
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream` (later `Video`, `Speech`, `Transcription`). Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
|
||||
|
||||
@@ -102,7 +102,7 @@ Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.
|
||||
|
||||
`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 `route.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 `route.decodeFrame` and raise stream-time failures with `route.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.
|
||||
|
||||
Transcription uses all three kinds (OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued): every route carries its `kind` and `TranscriptionClient` dispatches on it. `ImageRoute` is the same union; both clients dispatch through `MediaRoute.dispatch` and models compose through `composeAnyRoute`, and fal queue protocols come from `protocols/utils/fal-queue.ts`. Bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
|
||||
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
|
||||
|
||||
### URL Construction
|
||||
|
||||
@@ -275,6 +275,7 @@ Use this order for every protocol module:
|
||||
### Rules
|
||||
|
||||
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
|
||||
- Send `tool.inputSchema` as given. `prepareRequest` applies the tool schema rules (`ToolSchemaProjection.tools`) once per request, including tools in namespaces. A protocol whose API needs a model family's rules for every model declares `sanitizer` instead of transforming schemas itself.
|
||||
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
|
||||
|
||||
+12
-12
@@ -9,15 +9,13 @@ import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
|
||||
|
||||
const request = LLM.request({
|
||||
model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello in one short sentence.",
|
||||
generation: { maxTokens: 40 },
|
||||
})
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLM.generate(request)
|
||||
const response = yield* LLM.generate({
|
||||
model: openai.responses("gpt-4o-mini"), // `.chat(...)` selects the Chat Completions API instead
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello in one short sentence.",
|
||||
generation: { maxTokens: 40 },
|
||||
})
|
||||
console.log(response.text)
|
||||
})
|
||||
|
||||
@@ -25,7 +23,8 @@ const program = Effect.gen(function* () {
|
||||
await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
|
||||
```
|
||||
|
||||
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,
|
||||
Run `LLM.stream(...)` instead of `generate` when you want incremental `LLMEvent`s. Both accept input or a prebuilt
|
||||
`LLM.request(...)`. 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, video, speech, and transcription models. `Image.generate` resolves the
|
||||
@@ -72,10 +71,11 @@ helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Ef
|
||||
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 input = { model: openai.responses("gpt-4o-mini"), prompt: "Say hello." }
|
||||
const text = await ai.llm.generate(input)
|
||||
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." })) {
|
||||
for await (const event of ai.llm.stream(ai.llm.request(input))) {
|
||||
// LLMEvent
|
||||
}
|
||||
await ai.dispose()
|
||||
@@ -936,7 +936,7 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
- **`LLM.generate` / `LLM.stream`** — re-exported from `LLMClient` for one-import use.
|
||||
- **`LLM.generate` / `LLM.stream`** — run direct input or an `LLMRequest` through `LLMClient` for one-import use.
|
||||
- **`Message.user(...)` / `Message.assistant(...)` / `Message.tool(...)`** — message constructors from the canonical schema model.
|
||||
- **`LanguageModel.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
||||
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
|
||||
|
||||
@@ -140,7 +140,7 @@ 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.
|
||||
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`, like every modality route. `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.
|
||||
|
||||
#### Video
|
||||
|
||||
@@ -215,7 +215,7 @@ const request = Speech.request({
|
||||
})
|
||||
|
||||
const response = yield* Speech.generate(request) // SpeechResponse: audio: Media.Asset, timestamps?, usage?, providerMetadata?
|
||||
yield* Speech.stream(request) // Stream<SpeechEvent>: audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
|
||||
yield* Speech.stream(request) // Stream<SpeechEvent>: generation-queued | generation-progress | audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
|
||||
```
|
||||
|
||||
Execution is `MediaProtocol.stream` for every provider: one request whose body is framed and folded by a `step`
|
||||
@@ -389,7 +389,8 @@ for await (const event of generation.events({ poll: { interval: 10_000 } })) {
|
||||
const video = await generation.await({ poll: { interval: 10_000 }, signal })
|
||||
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 })
|
||||
const request = ai.llm.request({ model, prompt })
|
||||
const text = await ai.llm.generate(request)
|
||||
for await (const event of ai.llm.stream(request)) { … }
|
||||
|
||||
await ai.dispose()
|
||||
@@ -419,7 +420,7 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
|
||||
|
||||
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>` 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.
|
||||
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. As with `LanguageModel`, the route type does not carry `Options`, so `ImageModel<OpenAIImageOptions>` is an `ImageModel` and client methods take plain `ImageRequestFor`. 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,99 +1,30 @@
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import type { AwaitOptions, Generation } from "./generation.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import type { AIError } from "./schema/index.js"
|
||||
import { Context } from "effect"
|
||||
import { MediaClient } from "./media-client.js"
|
||||
import {
|
||||
responseEvents,
|
||||
ImageOutputEvent,
|
||||
ImageFinishEvent,
|
||||
type ImageEvent,
|
||||
type ImageModel,
|
||||
type ImageOptions,
|
||||
type ImageRequestFor,
|
||||
type ImageResponse,
|
||||
} from "./image.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly generate: <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
) => Effect.Effect<ImageResponse, AIError>
|
||||
readonly stream: <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
) => Stream.Stream<ImageEvent, AIError>
|
||||
readonly start: <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
) => Effect.Effect<Generation<ImageResponse>, AIError>
|
||||
readonly resume: <Options extends ImageOptions>(
|
||||
model: ImageModel<Options>,
|
||||
token: unknown,
|
||||
) => Effect.Effect<Generation<ImageResponse>, AIError>
|
||||
}
|
||||
export type Interface = MediaClient.Interface<ImageRequestFor, ImageEvent, ImageResponse>
|
||||
|
||||
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>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<ImageResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request, options)
|
||||
})
|
||||
|
||||
export const stream = <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<ImageEvent, AIError, Service> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return client.stream(request, options)
|
||||
}),
|
||||
)
|
||||
|
||||
export const start = <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.start(request)
|
||||
})
|
||||
|
||||
export const resume = <Options extends ImageOptions>(
|
||||
model: ImageModel<Options>,
|
||||
token: unknown,
|
||||
): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.resume(model, token)
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const dispatch = MediaRoute.dispatch<ImageEvent, ImageResponse>({
|
||||
modality: "image",
|
||||
execute: executor.execute,
|
||||
responseEvents,
|
||||
})
|
||||
return Service.of({
|
||||
start: (request) => dispatch.start(request.model.route, request),
|
||||
resume: (model, token) => dispatch.resume(model.route, model, token),
|
||||
generate: (request, options) => dispatch.generate(request.model.route, request, options),
|
||||
stream: (request, options) => dispatch.stream(request.model.route, request, options),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const ImageClient = {
|
||||
Service,
|
||||
layer,
|
||||
generate,
|
||||
stream,
|
||||
start,
|
||||
resume,
|
||||
...MediaClient.make(Service, {
|
||||
modality: "image",
|
||||
responseEvents: (response: ImageResponse) => [
|
||||
...response.images.map((image, index) => ImageOutputEvent.make({ index, image })),
|
||||
ImageFinishEvent.make({
|
||||
usage: response.usage,
|
||||
notices: response.notices,
|
||||
providerMetadata: response.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
} as const
|
||||
|
||||
+14
-65
@@ -1,9 +1,8 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js"
|
||||
import { Media } from "./media.js"
|
||||
import { MediaModel, composeAnyRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import type { MediaProtocol } from "./route/media-protocol.js"
|
||||
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
|
||||
import { ImageClient, Service } from "./image-client.js"
|
||||
|
||||
@@ -11,75 +10,39 @@ import { ImageClient, Service } from "./image-client.js"
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ImageOptions = Record<string, unknown>
|
||||
export type ImageOptions = MediaModel.Options
|
||||
|
||||
export type ImageRoute<Options extends ImageOptions = ImageOptions> = MediaRoute.AnyRoute<
|
||||
ImageRequestFor<Options>,
|
||||
ImageEvent,
|
||||
ImageResponse
|
||||
>
|
||||
export type ImageRoute = MediaRoute.AnyRoute<ImageRequestFor, ImageEvent, ImageResponse>
|
||||
|
||||
export class ImageModel<Options extends ImageOptions = ImageOptions> extends MediaModel<ImageRoute<Options>, Options> {
|
||||
export class ImageModel<Options extends ImageOptions = ImageOptions> extends MediaModel<ImageRoute, Options> {
|
||||
declare protected readonly _ImageModel: void
|
||||
|
||||
static make<Options extends ImageOptions = ImageOptions>(input: MediaModel.Input<ImageRoute<Options>>) {
|
||||
return new ImageModel<Options>(input)
|
||||
}
|
||||
|
||||
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
|
||||
static fromRoute<Options extends ImageOptions>(
|
||||
route: ImageModel.InlineRouteInput<Options>,
|
||||
route: MediaModel.InlineRouteInput<ImageRequestFor<Options>, ImageResponse>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): ImageModel<Options>
|
||||
static fromRoute<Options extends ImageOptions, Frame, State>(
|
||||
route: ImageModel.StreamRouteInput<Options, Frame, State>,
|
||||
route: MediaModel.StreamRouteInput<ImageRequestFor<Options>, ImageEvent, Frame, State>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): ImageModel<Options>
|
||||
static fromRoute<Options extends ImageOptions, Token>(
|
||||
route: ImageModel.QueuedRouteInput<Options, Token>,
|
||||
route: MediaModel.QueuedRouteInput<ImageRequestFor<Options>, ImageResponse, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): ImageModel<Options>
|
||||
static fromRoute<Options extends ImageOptions, Frame, State, Token>(
|
||||
route: ImageModel.RouteInput<Options, Frame, State, Token>,
|
||||
route: MediaModel.AnyRouteInput<ImageRequestFor<Options>, ImageEvent, ImageResponse, Frame, State, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
) {
|
||||
return new ImageModel<Options>({
|
||||
id: input.id,
|
||||
provider: route.protocol.provider,
|
||||
http: input.http,
|
||||
route: composeAnyRoute(route, input, collectResponse),
|
||||
route: composeRoute(route, input, collectResponse) as ImageRoute,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ImageModel {
|
||||
export type InlineRouteInput<Options extends ImageOptions = ImageOptions> = MediaModel.RouteInput<
|
||||
ImageRequestFor<Options>,
|
||||
MediaProtocol.Inline<ImageRequestFor<Options>, ImageResponse>
|
||||
>
|
||||
|
||||
export type StreamRouteInput<
|
||||
Options extends ImageOptions = ImageOptions,
|
||||
Frame = unknown,
|
||||
State = unknown,
|
||||
> = MediaModel.RouteInput<
|
||||
MediaProtocol.Addressed<ImageRequestFor<Options>>,
|
||||
MediaProtocol.Streamed<ImageRequestFor<Options>, ImageEvent, Frame, State>
|
||||
>
|
||||
|
||||
export type QueuedRouteInput<Options extends ImageOptions = ImageOptions, Token = unknown> = MediaModel.RouteInput<
|
||||
ImageRequestFor<Options>,
|
||||
MediaProtocol.Queued<ImageRequestFor<Options>, ImageResponse, Token>
|
||||
>
|
||||
|
||||
export type RouteInput<
|
||||
Options extends ImageOptions = ImageOptions,
|
||||
Frame = unknown,
|
||||
State = unknown,
|
||||
Token = unknown,
|
||||
> = MediaModel.AnyRouteInput<ImageRequestFor<Options>, ImageEvent, ImageResponse, Frame, State, Token>
|
||||
}
|
||||
|
||||
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||
expected: "Image.Model",
|
||||
})
|
||||
@@ -190,15 +153,6 @@ export const ImageEvent = Object.assign(imageEventTagged, {
|
||||
})
|
||||
export type ImageEvent = Schema.Schema.Type<typeof imageEventTagged>
|
||||
|
||||
export const responseEvents = (response: ImageResponse): ReadonlyArray<ImageEvent> => [
|
||||
...response.images.map((image, index) => ImageOutputEvent.make({ index, image })),
|
||||
ImageFinishEvent.make({
|
||||
usage: response.usage,
|
||||
notices: response.notices,
|
||||
providerMetadata: response.providerMetadata,
|
||||
}),
|
||||
]
|
||||
|
||||
const collectResponse = (events: ReadonlyArray<ImageEvent>): Effect.Effect<ImageResponse> => {
|
||||
const finish = events.find(ImageEvent.is.finish)
|
||||
// Every image protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
|
||||
@@ -232,36 +186,31 @@ export function request(input: ImageRequest | ImageRequestInput) {
|
||||
const requestEffect = (input: ImageRequest | ImageRequestInput) => tryRequest(() => request(input))
|
||||
|
||||
export function generate<const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model>,
|
||||
input: ImageRequest | ImageRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<ImageResponse, AIError, Service>
|
||||
export function generate(input: ImageRequest, options?: AwaitOptions): Effect.Effect<ImageResponse, AIError, Service>
|
||||
export function generate(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.generate(request, options)))
|
||||
}
|
||||
|
||||
export function stream<const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model>,
|
||||
input: ImageRequest | ImageRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<ImageEvent, AIError, Service>
|
||||
export function stream(input: ImageRequest, options?: AwaitOptions): Stream.Stream<ImageEvent, AIError, Service>
|
||||
export function stream(input: ImageRequest | ImageRequestInput, options?: AwaitOptions) {
|
||||
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => ImageClient.stream(request, options))))
|
||||
}
|
||||
|
||||
/** Inline and streaming routes fail with `UnsupportedOperation`. */
|
||||
export function start<const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model>,
|
||||
input: ImageRequest | ImageRequestInput<Model>,
|
||||
): Effect.Effect<Generation<ImageResponse>, AIError, Service>
|
||||
export function start(input: ImageRequest): Effect.Effect<Generation<ImageResponse>, AIError, Service>
|
||||
export function start(input: ImageRequest | ImageRequestInput) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => ImageClient.start(request)))
|
||||
}
|
||||
|
||||
export const resume = <Options extends ImageOptions>(
|
||||
model: ImageModel<Options>,
|
||||
token: unknown,
|
||||
): Effect.Effect<Generation<ImageResponse>, AIError, Service> => ImageClient.resume(model, token)
|
||||
export const resume = (model: ImageModel, token: unknown): Effect.Effect<Generation<ImageResponse>, AIError, Service> =>
|
||||
ImageClient.resume(model, token)
|
||||
|
||||
export const Image = {
|
||||
request,
|
||||
|
||||
+22
-4
@@ -1,5 +1,6 @@
|
||||
import { Effect, JsonSchema, Schema } from "effect"
|
||||
import { LLMClient, Service } from "./route/client.js"
|
||||
import { Effect, JsonSchema, Schema, Stream } from "effect"
|
||||
import { tryRequest } from "./media-model.js"
|
||||
import { LLMClient, Service, type StreamOptions } from "./route/client.js"
|
||||
import {
|
||||
GenerationOptions,
|
||||
HttpOptions,
|
||||
@@ -35,9 +36,26 @@ export type RequestInput<SelectedLanguageModel extends LanguageModel = LanguageM
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
export const generate = LLMClient.generate
|
||||
export function generate<const Model extends LanguageModel>(
|
||||
input: RequestInput<Model>,
|
||||
options?: StreamOptions,
|
||||
): Effect.Effect<LLMResponse, AIError, Service>
|
||||
export function generate(input: LLMRequest, options?: StreamOptions): Effect.Effect<LLMResponse, AIError, Service>
|
||||
export function generate(input: RequestInput | LLMRequest, options?: StreamOptions) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => LLMClient.generate(request, options)))
|
||||
}
|
||||
|
||||
export const stream = LLMClient.stream
|
||||
export function stream<const Model extends LanguageModel>(
|
||||
input: RequestInput<Model>,
|
||||
options?: StreamOptions,
|
||||
): Stream.Stream<LLMEvent, AIError, Service>
|
||||
export function stream(input: LLMRequest, options?: StreamOptions): Stream.Stream<LLMEvent, AIError, Service>
|
||||
export function stream(input: RequestInput | LLMRequest, options?: StreamOptions) {
|
||||
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => LLMClient.stream(request, options))))
|
||||
}
|
||||
|
||||
const requestEffect = (input: RequestInput | LLMRequest) =>
|
||||
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => request(input))
|
||||
|
||||
export const request = <const SelectedLanguageModel extends LanguageModel>(
|
||||
input: RequestInput<SelectedLanguageModel>,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type Context, Effect, Layer, Stream } from "effect"
|
||||
import { resultEvents, type AwaitOptions, type Generation, type Observation } from "./generation.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import type { MediaRoute } from "./route/media.js"
|
||||
import { AIError, UnsupportedOperationError } from "./schema/index.js"
|
||||
|
||||
/** A media request whose model carries the route that executes it. */
|
||||
export interface RoutedRequest<Self extends MediaRoute.MediaRequest, Event, Response> extends MediaRoute.MediaRequest {
|
||||
readonly model: MediaRoute.MediaRequest["model"] & { readonly route: MediaRoute.AnyRoute<Self, Event, Response> }
|
||||
}
|
||||
|
||||
/** `start` and `resume` fail with `UnsupportedOperation` on inline and stream routes. */
|
||||
export interface Interface<Req extends RoutedRequest<Req, Event, Response>, Event, Response> {
|
||||
readonly generate: (request: Req, options?: AwaitOptions) => Effect.Effect<Response, AIError>
|
||||
readonly stream: (request: Req, options?: AwaitOptions) => Stream.Stream<Event | Observation, AIError>
|
||||
readonly start: (request: Req) => Effect.Effect<Generation<Response>, AIError>
|
||||
readonly resume: (model: Req["model"], token: unknown) => Effect.Effect<Generation<Response>, AIError>
|
||||
}
|
||||
|
||||
/** One modality's layer and service accessors, dispatching each request on its route's `kind`. */
|
||||
export const make = <Self, Req extends RoutedRequest<Req, Event, Response>, Event, Response>(
|
||||
service: Context.Service<Self, Interface<Req, Event, Response>>,
|
||||
input: {
|
||||
readonly modality: string
|
||||
/** A completed response expanded into the streaming event shape. */
|
||||
readonly responseEvents: (response: Response) => ReadonlyArray<Event>
|
||||
},
|
||||
) => ({
|
||||
layer: Layer.effect(
|
||||
service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const notQueued = (route: MediaRoute.AnyRoute<Req, Event, Response>, operation: string) =>
|
||||
new AIError({
|
||||
reason: new UnsupportedOperationError({
|
||||
operation: `${input.modality}.${operation}`,
|
||||
provider: route.provider,
|
||||
route: route.id,
|
||||
message: `${route.provider}/${route.id} is not a queued route; use generate or stream`,
|
||||
}),
|
||||
})
|
||||
const start = (request: Req) => {
|
||||
const route = request.model.route
|
||||
if (route.kind !== "queued") return Effect.fail(notQueued(route, "start"))
|
||||
return route.start(request, executor.execute)
|
||||
}
|
||||
return service.of({
|
||||
start,
|
||||
resume: (model, token) => {
|
||||
if (model.route.kind !== "queued") return Effect.fail(notQueued(model.route, "resume"))
|
||||
return model.route.resume(model, token, executor.execute)
|
||||
},
|
||||
generate: (request, options) => {
|
||||
const route = request.model.route
|
||||
if (route.kind !== "queued") return route.generate(request, executor.execute)
|
||||
return start(request).pipe(Effect.flatMap((generation) => generation.await(options)))
|
||||
},
|
||||
stream: (request, options) => {
|
||||
const route = request.model.route
|
||||
if (route.kind === "stream") return route.stream(request, executor.execute)
|
||||
if (route.kind === "queued")
|
||||
return Stream.unwrap(
|
||||
start(request).pipe(Effect.map((generation) => resultEvents(generation, input.responseEvents, options))),
|
||||
)
|
||||
return Stream.fromIterableEffect(Effect.map(route.generate(request, executor.execute), input.responseEvents))
|
||||
},
|
||||
})
|
||||
}),
|
||||
),
|
||||
generate: (request: Req, options?: AwaitOptions) => service.use((client) => client.generate(request, options)),
|
||||
stream: (request: Req, options?: AwaitOptions) =>
|
||||
Stream.unwrap(service.useSync((client) => client.stream(request, options))),
|
||||
start: (request: Req) => service.use((client) => client.start(request)),
|
||||
resume: (model: Req["model"], token: unknown) => service.use((client) => client.resume(model, token)),
|
||||
})
|
||||
|
||||
export * as MediaClient from "./media-client.js"
|
||||
@@ -6,11 +6,13 @@ import { AIError, HttpOptions, InvalidRequestError, ModelID, ProviderID } from "
|
||||
|
||||
/**
|
||||
* What every media model carries: ids, the configured route, and deployment `http` overlays. Modality classes
|
||||
* (`ImageModel`, `VideoModel`, `SpeechModel`) extend it with their route type and a nominal marker so one cannot stand
|
||||
* in for the other in requests.
|
||||
* (`ImageModel`, `VideoModel`, `SpeechModel`, `TranscriptionModel`) extend it with their route type and a nominal
|
||||
* marker so one cannot stand in for the other in requests.
|
||||
*/
|
||||
export class MediaModel<Route, Options> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
// As with `LanguageModel`, the route type is erased over `Options`; `fromRoute` and the constructor trust that the
|
||||
// route accepts every request this model's `Options` admit.
|
||||
declare protected readonly _Options: Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: Route
|
||||
@@ -25,6 +27,8 @@ export class MediaModel<Route, Options> {
|
||||
}
|
||||
|
||||
export namespace MediaModel {
|
||||
export type Options = Record<string, unknown>
|
||||
|
||||
export interface Input<Route> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
@@ -41,48 +45,56 @@ export namespace MediaModel {
|
||||
readonly headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export type InlineRouteInput<Request extends MediaRoute.MediaRequest, Response> = RouteInput<
|
||||
Request,
|
||||
MediaProtocol.Inline<Request, Response>
|
||||
>
|
||||
|
||||
export type StreamRouteInput<Request extends MediaRoute.MediaRequest, Event, Frame, State> = RouteInput<
|
||||
MediaProtocol.Addressed<Request>,
|
||||
MediaProtocol.Streamed<Request, Event, Frame, State>
|
||||
>
|
||||
|
||||
export type QueuedRouteInput<Request extends MediaRoute.MediaRequest, Response, Token> = RouteInput<
|
||||
Request,
|
||||
MediaProtocol.Queued<Request, Response, Token>
|
||||
>
|
||||
|
||||
export type AnyRouteInput<Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token> =
|
||||
| RouteInput<Request, MediaProtocol.Inline<Request, Response>>
|
||||
| RouteInput<MediaProtocol.Addressed<Request>, MediaProtocol.Streamed<Request, Event, Frame, State>>
|
||||
| RouteInput<Request, MediaProtocol.Queued<Request, Response, Token>>
|
||||
| InlineRouteInput<Request, Response>
|
||||
| StreamRouteInput<Request, Event, Frame, State>
|
||||
| QueuedRouteInput<Request, Response, Token>
|
||||
}
|
||||
|
||||
/** Compose a protocol route input with one deployment through `MediaRoute.inline`, `queued`, or `stream`. */
|
||||
export const composeRoute = <Request extends MediaRoute.MediaRequest, Protocol, Route>(
|
||||
compose: (input: MediaRoute.Composition<Request> & { readonly protocol: Protocol }) => Route,
|
||||
route: MediaModel.RouteInput<Request, Protocol>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): Route =>
|
||||
compose({
|
||||
protocol: route.protocol,
|
||||
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
|
||||
auth: input.auth,
|
||||
headers:
|
||||
route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
|
||||
})
|
||||
|
||||
export const composeAnyRoute = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
|
||||
export const composeRoute = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
|
||||
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
collect: (events: ReadonlyArray<Event>) => Effect.Effect<Response, AIError>,
|
||||
): MediaRoute.AnyRoute<Request, Event, Response> => {
|
||||
if (isStreamInput(route))
|
||||
return composeRoute((composition) => MediaRoute.stream({ ...composition, collect }), route, input)
|
||||
if (isQueuedInput(route)) return composeRoute(MediaRoute.queued, route, input)
|
||||
return composeRoute(MediaRoute.inline, route, input)
|
||||
if (isStreamInput(route)) return MediaRoute.stream({ ...composition(route, input), collect })
|
||||
if (isQueuedInput(route)) return MediaRoute.queued(composition(route, input))
|
||||
return MediaRoute.inline(composition(route, input))
|
||||
}
|
||||
|
||||
const composition = <Request extends MediaRoute.MediaRequest, Protocol>(
|
||||
route: MediaModel.RouteInput<Request, Protocol>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): MediaRoute.Composition<Request> & { readonly protocol: Protocol } => ({
|
||||
protocol: route.protocol,
|
||||
endpoint: Endpoint.path(route.path, { baseURL: input.baseURL ?? route.baseURL }),
|
||||
auth: input.auth,
|
||||
headers:
|
||||
route.headers === undefined && input.headers === undefined ? undefined : { ...route.headers, ...input.headers },
|
||||
})
|
||||
|
||||
const isStreamInput = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
|
||||
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
|
||||
): route is MediaModel.RouteInput<
|
||||
MediaProtocol.Addressed<Request>,
|
||||
MediaProtocol.Streamed<Request, Event, Frame, State>
|
||||
> => route.protocol.kind === "stream"
|
||||
): route is MediaModel.StreamRouteInput<Request, Event, Frame, State> => route.protocol.kind === "stream"
|
||||
|
||||
const isQueuedInput = <Request extends MediaRoute.MediaRequest, Event, Response, Frame, State, Token>(
|
||||
route: MediaModel.AnyRouteInput<Request, Event, Response, Frame, State, Token>,
|
||||
): route is MediaModel.RouteInput<Request, MediaProtocol.Queued<Request, Response, Token>> =>
|
||||
route.protocol.kind === "queued"
|
||||
): route is MediaModel.QueuedRouteInput<Request, Response, Token> => route.protocol.kind === "queued"
|
||||
|
||||
/** Lift a synchronous Schema-class constructor into a typed `InvalidRequest` failure. */
|
||||
export const tryRequest = <A>(make: () => A): Effect.Effect<A, AIError> =>
|
||||
|
||||
+20
-33
@@ -1,23 +1,22 @@
|
||||
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
|
||||
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 { Image, type ImageModel, type ImageRequest, type ImageRequestInput } from "./image.js"
|
||||
import { LLM } from "./index.js"
|
||||
import { Media } from "./media.js"
|
||||
import { tryRequest } from "./media-model.js"
|
||||
import { RequestExecutor } from "./route/executor.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 { Speech, type SpeechModel, type SpeechRequest, type SpeechRequestInput } from "./speech.js"
|
||||
import {
|
||||
Transcription,
|
||||
TranscriptionModel,
|
||||
TranscriptionRequest,
|
||||
type TranscriptionOptions,
|
||||
type TranscriptionModel,
|
||||
type TranscriptionRequest,
|
||||
type TranscriptionRequestInput,
|
||||
} from "./transcription.js"
|
||||
import { fileMediaType } from "./utils/media-type.js"
|
||||
import { Video, VideoModel, VideoRequest, type VideoOptions, type VideoRequestInput } from "./video.js"
|
||||
import { Video, type VideoModel, type VideoRequest, type VideoRequestInput } from "./video.js"
|
||||
|
||||
/**
|
||||
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM, image, video, speech,
|
||||
@@ -93,17 +92,8 @@ export const make = (options: Options = {}) => {
|
||||
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 ? Effect.succeed(input) : tryRequest(() => LLM.request(input))
|
||||
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
|
||||
input instanceof ImageRequest ? input : Image.request(input)
|
||||
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
|
||||
input instanceof VideoRequest ? input : Video.request(input)
|
||||
const speechRequest = (input: SpeechRequestInput | SpeechRequest) =>
|
||||
input instanceof SpeechRequest ? input : Speech.request(input)
|
||||
const transcriptionRequest = (input: TranscriptionRequestInput | TranscriptionRequest) =>
|
||||
input instanceof TranscriptionRequest ? input : Transcription.request(input)
|
||||
|
||||
return {
|
||||
run,
|
||||
@@ -155,61 +145,58 @@ export const make = (options: Options = {}) => {
|
||||
generate: <const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model> | ImageRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => run(Image.generate(imageRequest(input), { poll: options?.poll }), options),
|
||||
) => run(Image.generate(input, { poll: options?.poll }), options),
|
||||
stream: <const Model extends ImageModel>(
|
||||
input: ImageRequestInput<Model> | ImageRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => iterate(Image.stream(imageRequest(input), { poll: options?.poll }), options),
|
||||
) => iterate(Image.stream(input, { poll: options?.poll }), options),
|
||||
start: <const Model extends ImageModel>(input: ImageRequestInput<Model> | ImageRequest, options?: RunOptions) =>
|
||||
run(Image.start(imageRequest(input)), options).then(handle),
|
||||
resume: <Options extends ImageOptions>(model: ImageModel<Options>, token: unknown, options?: RunOptions) =>
|
||||
run(Image.start(input), options).then(handle),
|
||||
resume: (model: ImageModel, token: unknown, options?: RunOptions) =>
|
||||
run(Image.resume(model, token), options).then(handle),
|
||||
},
|
||||
video: {
|
||||
request: Video.request,
|
||||
start: <const Model extends VideoModel>(input: VideoRequestInput<Model> | VideoRequest, options?: RunOptions) =>
|
||||
run(Video.start(videoRequest(input)), options).then(handle),
|
||||
run(Video.start(input), options).then(handle),
|
||||
generate: <const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model> | VideoRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => run(Video.generate(videoRequest(input), { poll: options?.poll }), options),
|
||||
resume: <Options extends VideoOptions>(model: VideoModel<Options>, token: unknown, options?: RunOptions) =>
|
||||
) => run(Video.generate(input, { poll: options?.poll }), options),
|
||||
resume: (model: VideoModel, token: unknown, options?: RunOptions) =>
|
||||
run(Video.resume(model, token), options).then(handle),
|
||||
stream: <const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model> | VideoRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => iterate(Video.stream(videoRequest(input), { poll: options?.poll }), options),
|
||||
) => iterate(Video.stream(input, { poll: options?.poll }), options),
|
||||
},
|
||||
speech: {
|
||||
request: Speech.request,
|
||||
generate: <const Model extends SpeechModel>(
|
||||
input: SpeechRequestInput<Model> | SpeechRequest,
|
||||
options?: RunOptions,
|
||||
) => run(Speech.generate(speechRequest(input)), options),
|
||||
) => run(Speech.generate(input), options),
|
||||
stream: <const Model extends SpeechModel>(
|
||||
input: SpeechRequestInput<Model> | SpeechRequest,
|
||||
options?: RunOptions,
|
||||
) => iterate(Speech.stream(speechRequest(input)), options),
|
||||
) => iterate(Speech.stream(input), options),
|
||||
},
|
||||
transcription: {
|
||||
request: Transcription.request,
|
||||
generate: <const Model extends TranscriptionModel>(
|
||||
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => run(Transcription.generate(transcriptionRequest(input), { poll: options?.poll }), options),
|
||||
) => run(Transcription.generate(input, { poll: options?.poll }), options),
|
||||
stream: <const Model extends TranscriptionModel>(
|
||||
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
|
||||
options?: AwaitOptions & RunOptions,
|
||||
) => iterate(Transcription.stream(transcriptionRequest(input), { poll: options?.poll }), options),
|
||||
) => iterate(Transcription.stream(input, { poll: options?.poll }), options),
|
||||
start: <const Model extends TranscriptionModel>(
|
||||
input: TranscriptionRequestInput<Model> | TranscriptionRequest,
|
||||
options?: RunOptions,
|
||||
) => run(Transcription.start(transcriptionRequest(input)), options).then(handle),
|
||||
resume: <Options extends TranscriptionOptions>(
|
||||
model: TranscriptionModel<Options>,
|
||||
token: unknown,
|
||||
options?: RunOptions,
|
||||
) => run(Transcription.resume(model, token), options).then(handle),
|
||||
) => run(Transcription.start(input), options).then(handle),
|
||||
resume: (model: TranscriptionModel, token: unknown, options?: RunOptions) =>
|
||||
run(Transcription.resume(model, token), options).then(handle),
|
||||
},
|
||||
dispose: () => runtime.dispose(),
|
||||
}
|
||||
|
||||
@@ -70,7 +70,11 @@ export const protocol = Protocol.make({
|
||||
return {
|
||||
...(yield* OpenAIChat.protocol.body.from(req)),
|
||||
enable_thinking: opts.enableThinking,
|
||||
thinking_budget: opts.thinkingBudget,
|
||||
// Alibaba also rejects an explicit budget that is not below `max_completion_tokens`.
|
||||
thinking_budget:
|
||||
opts.thinkingBudget === undefined
|
||||
? undefined
|
||||
: ProviderShared.fitThinkingBudget(opts.thinkingBudget, req.generation?.maxTokens),
|
||||
preserve_thinking: opts.preserveThinking,
|
||||
clear_thinking: opts.clearThinking,
|
||||
thinking: opts.thinking,
|
||||
|
||||
@@ -26,18 +26,21 @@ export const protocol = Protocol.make({
|
||||
from: Effect.fn("AlibabaMessages.fromRequest")(function* (req) {
|
||||
const opts = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(req.providerOptions ?? {})
|
||||
// Model Studio accepts enabled thinking without Anthropic's mandatory token budget.
|
||||
const body = yield* AnthropicMessages.protocol.body.from(
|
||||
LLMRequest.update(req, {
|
||||
providerOptions: { ...req.providerOptions, thinking: undefined },
|
||||
}),
|
||||
)
|
||||
const budget = opts.thinking?.budgetTokens ?? opts.thinking?.budget_tokens
|
||||
return {
|
||||
...(yield* AnthropicMessages.protocol.body.from(
|
||||
LLMRequest.update(req, {
|
||||
providerOptions: { ...req.providerOptions, thinking: undefined },
|
||||
}),
|
||||
)),
|
||||
...body,
|
||||
thinking:
|
||||
opts.thinking === undefined
|
||||
? undefined
|
||||
: {
|
||||
type: opts.thinking.type,
|
||||
budget_tokens: opts.thinking.budgetTokens ?? opts.thinking.budget_tokens,
|
||||
budget_tokens:
|
||||
budget === undefined ? undefined : ProviderShared.fitThinkingBudget(budget, body.max_tokens),
|
||||
},
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type CacheHint,
|
||||
type FinishReasonDetails,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
@@ -31,13 +30,13 @@ import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
|
||||
import * as Cache from "./utils/cache.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "anthropic-messages"
|
||||
export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
|
||||
export const PATH = "/messages"
|
||||
export const DEFAULT_MAX_TOKENS = 32_000
|
||||
const MIN_THINKING_BUDGET = 1_024
|
||||
const DEFAULT_EFFORT = "high"
|
||||
|
||||
const SSE_EVENTS = new Set([
|
||||
@@ -524,10 +523,10 @@ const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: s
|
||||
return typeof provider.redactedData === "string" ? provider.redactedData : undefined
|
||||
}
|
||||
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
|
||||
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: inputSchema,
|
||||
input_schema: tool.inputSchema,
|
||||
cache_control: cacheControl(breakpoints, tool.cache),
|
||||
})
|
||||
|
||||
@@ -1027,6 +1026,15 @@ const applyThinkingBindingDefault = (model: LLMRequest["model"], thinking: Anthr
|
||||
}
|
||||
}
|
||||
|
||||
// Anthropic also requires an explicit thinking budget below `max_tokens` and at or above its minimum.
|
||||
const fitThinking = (thinking: AnthropicThinking | undefined, maxTokens: number) =>
|
||||
thinking?.type === "enabled"
|
||||
? {
|
||||
...thinking,
|
||||
budget_tokens: ProviderShared.fitThinkingBudget(thinking.budget_tokens, maxTokens, MIN_THINKING_BUDGET),
|
||||
}
|
||||
: thinking
|
||||
|
||||
const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const management = options.contextManagement
|
||||
@@ -1039,12 +1047,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
const breakpoints = Cache.newBreakpoints(ANTHROPIC_BREAKPOINT_CAP)
|
||||
const flattened = ProviderShared.flattenToolRequest(updates.request)
|
||||
const tools =
|
||||
flattened.tools.length === 0
|
||||
? undefined
|
||||
: flattened.tools.map((tool) =>
|
||||
lowerTool(breakpoints, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
|
||||
)
|
||||
const tools = flattened.tools.length === 0 ? undefined : flattened.tools.map((tool) => lowerTool(breakpoints, tool))
|
||||
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
|
||||
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
||||
const systemParts = request.system.filter((part) => part.text.length > 0)
|
||||
@@ -1064,6 +1067,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
}
|
||||
const output_config =
|
||||
updates.effort === undefined && format === undefined ? undefined : { effort: updates.effort, format }
|
||||
const maxTokens = generation?.maxTokens ?? DEFAULT_MAX_TOKENS
|
||||
const body = {
|
||||
model: request.model.id,
|
||||
system,
|
||||
@@ -1071,12 +1075,12 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
tools,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: generation?.maxTokens ?? DEFAULT_MAX_TOKENS,
|
||||
max_tokens: maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
top_p: generation?.topP,
|
||||
top_k: generation?.topK,
|
||||
stop_sequences: generation?.stop,
|
||||
thinking: applyThinkingBindingDefault(request.model, options.thinking),
|
||||
thinking: applyThinkingBindingDefault(request.model, fitThinking(options.thinking, maxTokens)),
|
||||
output_config,
|
||||
// top-level passthrough per SDK MessageCreateParamsBase:4638,4643,4649,4654,4670
|
||||
cache_control: options.cache_control ?? options.cacheControl,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type CacheHint,
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type LanguageModel,
|
||||
type ProviderMetadata,
|
||||
@@ -26,7 +25,6 @@ import { BedrockCache } from "./utils/bedrock-cache.js"
|
||||
import { BedrockMedia } from "./utils/bedrock-media.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
import { concatBytes } from "../utils/bytes.js"
|
||||
|
||||
@@ -221,22 +219,18 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
|
||||
const lowerToolSpec = (tool: ToolDefinition): BedrockToolSpec => ({
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
|
||||
inputSchema: { json: inputSchema },
|
||||
inputSchema: { json: tool.inputSchema },
|
||||
},
|
||||
})
|
||||
|
||||
const lowerTools = (
|
||||
model: LanguageModel,
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
tools: ReadonlyArray<ToolDefinition>,
|
||||
): BedrockTool[] => {
|
||||
const lowerTools = (breakpoints: BedrockCache.Breakpoints, tools: ReadonlyArray<ToolDefinition>): BedrockTool[] => {
|
||||
const result: BedrockTool[] = []
|
||||
for (const tool of tools) {
|
||||
result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model)))
|
||||
result.push(lowerToolSpec(tool))
|
||||
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
|
||||
if (cachePoint) result.push(cachePoint)
|
||||
}
|
||||
@@ -441,19 +435,39 @@ const isHighReasoningEffort = Schema.is(
|
||||
}),
|
||||
)
|
||||
|
||||
const Options = Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Struct({ type: Schema.Literal("enabled"), budgetTokens: Schema.Number })),
|
||||
})
|
||||
export type OptionsInput = typeof Options.Type
|
||||
const decodeOptions = ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))
|
||||
// Claude on Bedrock requires the thinking budget below `maxTokens`, with a minimum of 1,024.
|
||||
const MIN_THINKING_BUDGET = 1_024
|
||||
|
||||
const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request: LLMRequest) {
|
||||
const toolChoice = request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const generation = request.generation
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const maxTokens =
|
||||
isNova2(request.model) && isHighReasoningEffort(request.http?.body) ? undefined : generation?.maxTokens
|
||||
const thinking =
|
||||
options.thinking === undefined
|
||||
? undefined
|
||||
: {
|
||||
type: "enabled",
|
||||
budget_tokens: ProviderShared.fitThinkingBudget(
|
||||
options.thinking.budgetTokens,
|
||||
maxTokens,
|
||||
MIN_THINKING_BUDGET,
|
||||
),
|
||||
}
|
||||
// Bedrock-Claude shares Anthropic's 4-breakpoint cap. Spend the budget in
|
||||
// tools → system → messages order to favour the highest-impact prefixes.
|
||||
const breakpoints = BedrockCache.breakpoints(request.model.id)
|
||||
const toolConfig = (() => {
|
||||
if (flattened.tools.length === 0) return undefined
|
||||
return {
|
||||
tools: lowerTools(request.model, breakpoints, flattened.tools),
|
||||
tools: lowerTools(breakpoints, flattened.tools),
|
||||
// Converse has no native "none". Keep definitions stable for prompt
|
||||
// caching and omit only the unsupported choice.
|
||||
toolChoice,
|
||||
@@ -487,9 +501,15 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
system,
|
||||
inferenceConfig,
|
||||
toolConfig,
|
||||
// Converse's base inferenceConfig has no topK; Anthropic/Nova accept it
|
||||
// as a model-specific field, so it goes through additionalModelRequestFields.
|
||||
additionalModelRequestFields: generation?.topK === undefined ? undefined : { top_k: generation.topK },
|
||||
// Converse's base inferenceConfig has no topK or thinking; Anthropic/Nova accept them
|
||||
// as model-specific fields, so they go through additionalModelRequestFields.
|
||||
additionalModelRequestFields:
|
||||
generation?.topK === undefined && thinking === undefined
|
||||
? undefined
|
||||
: {
|
||||
...(generation?.topK === undefined ? {} : { top_k: generation.topK }),
|
||||
...(thinking === undefined ? {} : { thinking }),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type LLMRequest,
|
||||
type LanguageModel,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
type ProviderOptions,
|
||||
@@ -24,11 +23,12 @@ import { Media } from "../media.js"
|
||||
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
|
||||
const ADAPTER = "gemini"
|
||||
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
|
||||
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
||||
// Gemini 2.5 rejects a budget under the model's minimum: 512 on Flash-Lite, the highest, and 128 on Pro.
|
||||
const MIN_THINKING_BUDGET = 512
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
||||
// Gemini 3 rejects replayed function calls without a thought signature. Google's SDKs avoid that in normal chats by
|
||||
@@ -268,12 +268,11 @@ interface ParserState {
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema. Gemini's schema
|
||||
// rules are this API's default, including for tuned endpoints whose IDs do not name Gemini.
|
||||
const lowerTool = (tool: ToolDefinition, model: LanguageModel) => ({
|
||||
// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema.
|
||||
const lowerTool = (tool: ToolDefinition) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parametersJsonSchema: ToolSchemaProjection.modelCompatibility(tool.inputSchema, model, "gemini"),
|
||||
parametersJsonSchema: tool.inputSchema,
|
||||
})
|
||||
|
||||
const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -452,10 +451,22 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
presencePenalty: generation?.presencePenalty,
|
||||
seed: generation?.seed,
|
||||
stopSequences: generation?.stop,
|
||||
// Gemini accepts a budget above `maxOutputTokens`, but thinking then leaves the answer empty.
|
||||
thinkingConfig:
|
||||
options.thinkingConfig === undefined
|
||||
? undefined
|
||||
: { ...options.thinkingConfig, includeThoughts: options.thinkingConfig.includeThoughts ?? true },
|
||||
: {
|
||||
...options.thinkingConfig,
|
||||
includeThoughts: options.thinkingConfig.includeThoughts ?? true,
|
||||
thinkingBudget:
|
||||
options.thinkingConfig.thinkingBudget === undefined
|
||||
? undefined
|
||||
: ProviderShared.fitThinkingBudget(
|
||||
options.thinkingConfig.thinkingBudget,
|
||||
generation?.maxTokens,
|
||||
MIN_THINKING_BUDGET,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -468,7 +479,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
tools: hasTools
|
||||
? [
|
||||
{
|
||||
functionDeclarations: flattened.tools.map((tool) => lowerTool(tool, request.model)),
|
||||
functionDeclarations: flattened.tools.map(lowerTool),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
@@ -804,6 +815,8 @@ export const protocol = Protocol.make({
|
||||
schema: GeminiBody,
|
||||
from: fromRequest,
|
||||
},
|
||||
// Gemini's schema rules are this API's default, including for tuned endpoints whose IDs do not name Gemini.
|
||||
sanitizer: "gemini",
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(GeminiEvent),
|
||||
initial: (request) => ({
|
||||
|
||||
@@ -56,10 +56,15 @@ interface State extends SpeechStream.Audio, GeminiGenerateContent.Metadata {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
if (request.format === "pcm" && request.mode === "generate" && /^gemini-3\.8-.*-tts(?:-|$)/.test(request.model.id))
|
||||
return yield* route.unsupported(
|
||||
"media.format",
|
||||
`${route.name} returns WAV by default for Gemini 3.8 TTS unary requests; omit the format to accept it`,
|
||||
)
|
||||
if (request.format !== undefined && request.format !== "pcm")
|
||||
return yield* route.unsupported(
|
||||
"media.format",
|
||||
`${route.name} only returns raw PCM; request format "pcm" or omit it, then wrap the samples yourself`,
|
||||
`${route.name} only accepts raw PCM as an explicit format; omit it to accept the provider's default output`,
|
||||
)
|
||||
const voiceName = SpeechStream.voiceID(request.voice)
|
||||
return MediaProtocol.json(
|
||||
@@ -97,10 +102,18 @@ const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: stri
|
||||
return [next, audio.flatMap((part) => SpeechStream.delta(next, part.data)[1])] as const
|
||||
})
|
||||
|
||||
const finish = (state: State) => {
|
||||
const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) => {
|
||||
const sampleRate = SpeechStream.sampleRate(state.mimeType) ?? DEFAULT_SAMPLE_RATE
|
||||
const output =
|
||||
state.mimeType?.split(";")[0]?.toLowerCase() === "audio/wav"
|
||||
? SpeechStream.container("wav", sampleRate)
|
||||
: SpeechStream.pcm("pcm_s16le", sampleRate, state.mimeType ?? `audio/L16;codec=pcm;rate=${sampleRate}`)
|
||||
if (context.request.format === "pcm" && output.info.format !== "pcm")
|
||||
return Effect.fail(
|
||||
route.frameError(`Google Speech returned ${output.info.format} instead of the requested raw PCM`),
|
||||
)
|
||||
return SpeechStream.finish(route, state, {
|
||||
...SpeechStream.pcm("pcm_s16le", sampleRate, state.mimeType ?? `audio/L16;codec=pcm;rate=${sampleRate}`),
|
||||
...output,
|
||||
usage: GeminiGenerateContent.usage(state.usage),
|
||||
providerMetadata: GeminiGenerateContent.providerMetadata(state),
|
||||
detail: state.finishReason === undefined ? undefined : `finish reason: ${state.finishReason}`,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { LLMEvent, LLMRequest, Message, ToolResultPart } from "../schema/index.j
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { detectMediaType } from "../utils/media-type.js"
|
||||
|
||||
const ADAPTER = "meta-responses"
|
||||
@@ -103,12 +102,7 @@ const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: L
|
||||
? undefined
|
||||
: yield* Effect.forEach(projected.tools, (tool) =>
|
||||
Effect.gen(function* () {
|
||||
if (tool.native === undefined)
|
||||
return yield* OpenResponses.lowerTool(
|
||||
NAME,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model),
|
||||
)
|
||||
if (tool.native === undefined) return yield* OpenResponses.lowerTool(NAME, tool)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
UnknownProviderError,
|
||||
Usage,
|
||||
type FinishReasonDetails,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ToolCallPart,
|
||||
@@ -23,7 +22,6 @@ import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "mistral-chat"
|
||||
@@ -368,9 +366,9 @@ const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request:
|
||||
return messages
|
||||
})
|
||||
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): MistralTool => ({
|
||||
const lowerTool = (tool: ToolDefinition): MistralTool => ({
|
||||
type: "function",
|
||||
function: { name: tool.name, description: tool.description, parameters: inputSchema, strict: false },
|
||||
function: { name: tool.name, description: tool.description, parameters: tool.inputSchema, strict: false },
|
||||
})
|
||||
|
||||
export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (request: LLMRequest) {
|
||||
@@ -396,12 +394,7 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(flattened.request),
|
||||
tools:
|
||||
flattened.tools.length > 0
|
||||
? flattened.tools.map((tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
|
||||
)
|
||||
: undefined,
|
||||
tools: flattened.tools.length > 0 ? flattened.tools.map(lowerTool) : undefined,
|
||||
tool_choice: toolChoice,
|
||||
stream: true as const,
|
||||
max_tokens: request.generation?.maxTokens,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
ProviderInternalError,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
@@ -24,7 +23,6 @@ import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { effortUpdate } from "../effort-updates.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "open-responses"
|
||||
@@ -443,23 +441,24 @@ interface ReasoningStreamItem {
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (
|
||||
protocolName: string,
|
||||
tool: ToolDefinition,
|
||||
inputSchema: JsonSchema,
|
||||
) {
|
||||
export const lowerTool = Effect.fn("OpenResponses.lowerTool")(function* (protocolName: string, tool: ToolDefinition) {
|
||||
if (tool.native !== undefined)
|
||||
return yield* ProviderShared.invalidRequest(`${protocolName} does not support provider-native tool ${tool.name}`)
|
||||
return {
|
||||
type: "function" as const,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: inputSchema,
|
||||
parameters: tool.inputSchema,
|
||||
// The common tool definition does not currently express Responses strict-schema policy.
|
||||
strict: false,
|
||||
}
|
||||
})
|
||||
|
||||
export const lowerTools = (tools: ReadonlyArray<ToolDefinition>, adapter: ProviderAdapter) =>
|
||||
Effect.forEach(tools, (tool) =>
|
||||
tool.native !== undefined && adapter.nativeTool ? adapter.nativeTool(tool.native) : lowerTool(adapter.name, tool),
|
||||
)
|
||||
|
||||
export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
ProviderShared.matchToolChoice(protocolName, toolChoice, {
|
||||
auto: () => "auto" as const,
|
||||
@@ -821,14 +820,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
return {
|
||||
...(yield* lowerConversation(projected.request, adapter)),
|
||||
...lowerGeneration(request),
|
||||
tools:
|
||||
projected.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(projected.tools, (tool) =>
|
||||
tool.native !== undefined && adapter.nativeTool
|
||||
? adapter.nativeTool(tool.native)
|
||||
: lowerTool(adapter.name, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
|
||||
),
|
||||
tools: projected.tools.length === 0 ? undefined : yield* lowerTools(projected.tools, adapter),
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
type FinishReason,
|
||||
type FinishReasonDetails,
|
||||
type CacheHint,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ReasoningPart,
|
||||
@@ -29,7 +28,6 @@ import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIOptions } from "./utils/openai-options.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "openai-chat"
|
||||
@@ -330,17 +328,12 @@ interface LoweringOptions {
|
||||
readonly toolCallID?: (id: string) => string
|
||||
}
|
||||
|
||||
const lowerTool = (
|
||||
tool: ToolDefinition,
|
||||
inputSchema: JsonSchema,
|
||||
options: LoweringOptions,
|
||||
supportsStrictMode: boolean,
|
||||
): OpenAIChatTool => ({
|
||||
const lowerTool = (tool: ToolDefinition, options: LoweringOptions, supportsStrictMode: boolean): OpenAIChatTool => ({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: inputSchema,
|
||||
parameters: tool.inputSchema,
|
||||
...(supportsStrictMode ? { strict: false } : {}),
|
||||
},
|
||||
cache_control: options.cacheControl?.(tool.cache),
|
||||
@@ -825,14 +818,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
? hasHistory
|
||||
? []
|
||||
: undefined
|
||||
: flattened.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model),
|
||||
options,
|
||||
supportsStrictMode,
|
||||
),
|
||||
),
|
||||
: flattened.tools.map((tool) => lowerTool(tool, options, supportsStrictMode)),
|
||||
tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
|
||||
|
||||
@@ -5,20 +5,12 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import {
|
||||
LLMRequest,
|
||||
mergeJsonRecords,
|
||||
type JsonSchema,
|
||||
type LanguageModel,
|
||||
type ToolDefinition,
|
||||
type ToolEntry,
|
||||
} from "../schema/index.js"
|
||||
import { LLMRequest, type ToolDefinition, type ToolEntry } from "../schema/index.js"
|
||||
import { resolveEffortUpdates } from "../effort-updates.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { ResponsesHostedTools } from "./utils/responses-hosted-tools.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { ResponsesCompaction } from "./utils/responses-compaction.js"
|
||||
import { ResponsesCheckpoint } from "./utils/responses-checkpoint.js"
|
||||
@@ -143,11 +135,6 @@ export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compactio
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenAIResponsesInputItem, CompactionTrigger])),
|
||||
store: Schema.Literal(false),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
Schema.Struct({ mode: Schema.optional(Schema.String), ttl: Schema.optional(Schema.String) }),
|
||||
),
|
||||
})
|
||||
|
||||
const adapter = {
|
||||
@@ -174,20 +161,19 @@ const nativeImageTool = (tool: ToolDefinition) => {
|
||||
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
|
||||
}
|
||||
|
||||
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) {
|
||||
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition) {
|
||||
const native = nativeImageToolInput(tool)
|
||||
if (native !== undefined) {
|
||||
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
|
||||
return yield* ProviderShared.invalidRequest("OpenAI Responses image generation tool options are invalid")
|
||||
}
|
||||
return yield* OpenResponses.lowerTool(NAME, tool, inputSchema)
|
||||
return yield* OpenResponses.lowerTool(NAME, tool)
|
||||
})
|
||||
|
||||
// Native namespaces hold only function tools, so deeper levels flatten into
|
||||
// the leaf names the same way non-native protocols flatten the whole tree.
|
||||
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry, model: LanguageModel) {
|
||||
if (tool.type === "tool")
|
||||
return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model))
|
||||
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry) {
|
||||
if (tool.type === "tool") return yield* lowerTool(tool)
|
||||
// OpenAI requires a namespace description; fall back to a generic one so a
|
||||
// missing description never blocks the request.
|
||||
return {
|
||||
@@ -195,11 +181,13 @@ const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (to
|
||||
name: tool.name,
|
||||
description: tool.description ?? `Tools in the ${tool.name} namespace.`,
|
||||
tools: yield* Effect.forEach(ProviderShared.flattenTools(tool.tools), (leaf) =>
|
||||
OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, model)),
|
||||
OpenResponses.lowerTool(NAME, leaf),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const lowerTools = (request: LLMRequest) => Effect.forEach(request.tools, lowerToolEntry)
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolEntry>) =>
|
||||
ProviderShared.matchToolChoice(NAME, toolChoice, {
|
||||
auto: () => "auto" as const,
|
||||
@@ -223,10 +211,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
|
||||
context_management: management?.map((edit) => ({ type: edit.type, compact_threshold: edit.compactThreshold })),
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, request.model)),
|
||||
tools: request.tools.length === 0 ? undefined : yield* lowerTools(request),
|
||||
tool_choice:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
@@ -238,7 +223,6 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
const checkpointBody = {
|
||||
schema: CheckpointBody,
|
||||
from: Effect.fn("OpenAIResponses.checkpointBody")(function* (request: LLMRequest) {
|
||||
const native = yield* fromRequest(LLMRequest.update(request, { toolChoice: undefined }))
|
||||
const overlay = request.http?.body
|
||||
// Complete history is required for stateless replay and SSE recovery. Raw input overrides bypass that contract.
|
||||
if (
|
||||
@@ -249,18 +233,13 @@ const checkpointBody = {
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
"Trigger compaction requires complete canonical history, not an input or continuation override",
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(CheckpointBody))({
|
||||
...mergeJsonRecords(native, overlay),
|
||||
input: [...native.input, { type: "compaction_trigger" }],
|
||||
stream: true,
|
||||
store: false,
|
||||
parallel_tool_calls: true,
|
||||
tool_choice: undefined,
|
||||
context_management: undefined,
|
||||
text: undefined,
|
||||
max_output_tokens: undefined,
|
||||
max_tool_calls: undefined,
|
||||
})
|
||||
if (overlay?.stream !== undefined && overlay.stream !== true)
|
||||
return yield* ProviderShared.invalidRequest("Trigger compaction requires a streamed response")
|
||||
const native = yield* fromRequest(request)
|
||||
return {
|
||||
...native,
|
||||
input: [...native.input, { type: "compaction_trigger" as const }],
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -342,7 +321,10 @@ export const transport = channelTransport({
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
compact: { endpoint: ResponsesCompaction.make(adapter), trigger: ResponsesCheckpoint.make(checkpointBody) },
|
||||
compact: {
|
||||
endpoint: ResponsesCompaction.make(adapter, lowerTools),
|
||||
trigger: ResponsesCheckpoint.make(checkpointBody),
|
||||
},
|
||||
id: ADAPTER,
|
||||
provider: "openai",
|
||||
providerMetadataKey: "openai",
|
||||
|
||||
@@ -110,6 +110,14 @@ export const sumTokens = (...values: ReadonlyArray<number | undefined>): number
|
||||
return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps an explicit thinking budget at half the output limit. Thinking counts against the output limit, so a budget
|
||||
* near it leaves the answer, a tool call, or a summary without room. Smaller budgets, special values such as `-1` and
|
||||
* `0`, and requests without an output limit pass through unchanged.
|
||||
*/
|
||||
export const fitThinkingBudget = (budget: number, maxTokens: number | undefined, minimum = 1) =>
|
||||
maxTokens === undefined || budget <= maxTokens / 2 ? budget : Math.max(minimum, Math.floor(maxTokens / 2))
|
||||
|
||||
export const eventError = (route: string, message: string, body?: string, cause?: unknown) =>
|
||||
new AIError({
|
||||
reason: new InvalidProviderOutputError({ route, message, body, cause }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Route, type RouteBody, type TriggerCompactOperation } from "../../route/client.js"
|
||||
import { Protocol } from "../../route/protocol.js"
|
||||
import { CompactionCheckpointResponse, HttpOptions, LLMEvent, LLMRequest } from "../../schema/index.js"
|
||||
import { CompactionCheckpointResponse, LLMEvent, LLMRequest } from "../../schema/index.js"
|
||||
import { OpenResponses } from "../open-responses.js"
|
||||
import { ProviderShared } from "../shared.js"
|
||||
|
||||
@@ -109,12 +109,8 @@ export const make = <Body>(body: RouteBody<Body>): TriggerCompactOperation =>
|
||||
transport: source.transport,
|
||||
})
|
||||
const native = yield* body.from(request)
|
||||
// The body builder already applied and validated overlays. Do not let transport reapply them.
|
||||
const preparedRequest = LLMRequest.update(request, {
|
||||
http: request.http === undefined ? undefined : new HttpOptions({ ...request.http, body: undefined }),
|
||||
})
|
||||
const prepared = yield* route.prepareTransport(native, preparedRequest, options)
|
||||
yield* route.streamPrepared(prepared, preparedRequest, { http: executor }, options).pipe(Stream.runDrain)
|
||||
const prepared = yield* route.prepareTransport(native, request, options)
|
||||
yield* route.streamPrepared(prepared, request, { http: executor }, options).pipe(Stream.runDrain)
|
||||
if (!result) return yield* ProviderShared.eventError(source.id, "Compaction response ended without a checkpoint")
|
||||
return result
|
||||
})
|
||||
|
||||
@@ -19,12 +19,18 @@ import { OpenResponses } from "../open-responses.js"
|
||||
import { JsonObject, optionalNull, ProviderShared } from "../shared.js"
|
||||
import { Media } from "../../media.js"
|
||||
|
||||
// /compact has a smaller wire contract than /responses; keep the request controls it accepts.
|
||||
const Body = Schema.Struct({
|
||||
model: Schema.String,
|
||||
input: Schema.Array(Schema.Unknown),
|
||||
instructions: optionalNull(Schema.String),
|
||||
previous_response_id: optionalNull(Schema.String),
|
||||
service_tier: optionalNull(Schema.String),
|
||||
reasoning: Schema.optional(JsonObject),
|
||||
text: Schema.optional(JsonObject),
|
||||
include: OpenResponses.coreFields.include,
|
||||
parallel_tool_calls: OpenResponses.coreFields.parallel_tool_calls,
|
||||
tools: Schema.optional(Schema.Array(JsonObject)),
|
||||
prompt_cache_key: optionalNull(Schema.String),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
@@ -74,17 +80,27 @@ const Response = Schema.Struct({
|
||||
usage: Schema.optional(Schema.StructWithRest(OpenResponses.OpenResponsesUsage, [JsonObject])),
|
||||
})
|
||||
|
||||
export const make = (adapter: OpenResponses.ProviderAdapter): CompactOperation =>
|
||||
export const make = (
|
||||
adapter: OpenResponses.ProviderAdapter,
|
||||
lowerTools: (request: LLMRequest) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, AIError>,
|
||||
): CompactOperation =>
|
||||
Effect.fn("ResponsesCompaction.execute")(function* (request, executor, options) {
|
||||
const route = request.model.route
|
||||
// The standalone compaction endpoint rejects histories containing configuration updates.
|
||||
const native = yield* OpenResponses.lowerConversation(stripEffortUpdates(request), adapter)
|
||||
const generation = OpenResponses.lowerGeneration(request)
|
||||
const tools = request.tools.length === 0 ? undefined : yield* lowerTools(request)
|
||||
const body = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
...native,
|
||||
service_tier: request.providerOptions?.serviceTier,
|
||||
prompt_cache_key: ProviderShared.promptCacheKey(request),
|
||||
service_tier: generation.service_tier,
|
||||
reasoning: generation.reasoning,
|
||||
text: generation.text,
|
||||
include: generation.include,
|
||||
parallel_tool_calls: generation.parallel_tool_calls,
|
||||
tools,
|
||||
prompt_cache_key: generation.prompt_cache_key,
|
||||
},
|
||||
request.http?.body,
|
||||
),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { JsonSchema, LanguageModel, LanguageModelSanitizerCompatibility } from "../../schema/index.js"
|
||||
import { ToolDefinition, type JsonSchema, type LanguageModel, type LLMRequest } from "../../schema/index.js"
|
||||
import { isRecord } from "../../utils/record.js"
|
||||
import { GeminiJsonSchema } from "./gemini-json-schema.js"
|
||||
|
||||
@@ -70,13 +70,13 @@ const objectRoot = (schema: JsonSchema): JsonSchema => {
|
||||
// Otherwise the protocol's own default applies (the Gemini API always uses Gemini's rules), then the
|
||||
// model name selects the family's rules so models reached through gateways and OpenAI-compatible
|
||||
// endpoints get the same handling.
|
||||
const modelCompatibility = (
|
||||
schema: JsonSchema,
|
||||
model: LanguageModel,
|
||||
protocolDefault?: LanguageModelSanitizerCompatibility,
|
||||
): JsonSchema => {
|
||||
const modelCompatibility = (schema: JsonSchema, model: LanguageModel): JsonSchema => {
|
||||
const root = objectRoot(schema)
|
||||
switch (model.compatibility?.sanitizer ?? protocolDefault ?? MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]) {
|
||||
switch (
|
||||
model.compatibility?.sanitizer ??
|
||||
model.route.sanitizer ??
|
||||
MODEL_NAMES.find(([name]) => name.test(model.id))?.[1]
|
||||
) {
|
||||
case "gemini":
|
||||
return gemini(root)
|
||||
case "moonshot":
|
||||
@@ -87,10 +87,18 @@ const modelCompatibility = (
|
||||
}
|
||||
}
|
||||
|
||||
// Applied once to every request before any protocol builds its body, including tools in namespaces.
|
||||
const tools = (entries: LLMRequest["tools"], model: LanguageModel): LLMRequest["tools"] =>
|
||||
entries.map((tool) =>
|
||||
tool.type === "tool"
|
||||
? new ToolDefinition({ ...tool, inputSchema: modelCompatibility(tool.inputSchema, model) })
|
||||
: { ...tool, tools: tools(tool.tools, model) },
|
||||
)
|
||||
|
||||
export const ToolSchemaProjection = {
|
||||
gemini,
|
||||
modelCompatibility,
|
||||
moonshot,
|
||||
openAI,
|
||||
responses,
|
||||
tools,
|
||||
} as const
|
||||
|
||||
@@ -50,7 +50,8 @@ const fromRequest = Effect.fn("XAIResponses.fromRequest")(function* (request: LL
|
||||
operation: "in-band-compaction",
|
||||
provider: request.model.provider,
|
||||
route: request.model.route.id,
|
||||
message: "xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
|
||||
message:
|
||||
"xAI requires explicit compaction through LLMClient.compact; automatic context management is not supported",
|
||||
})
|
||||
return yield* decodeBody(yield* OpenResponses.fromRequestWithAdapter(request, adapter))
|
||||
})
|
||||
@@ -93,6 +94,8 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
})
|
||||
|
||||
export const compact = ResponsesCompaction.make(adapter)
|
||||
export const compact = ResponsesCompaction.make(adapter, (request) =>
|
||||
OpenResponses.lowerTools(ProviderShared.flattenTools(request.tools), adapter),
|
||||
)
|
||||
|
||||
export * as XAIResponses from "./xai-responses.js"
|
||||
|
||||
@@ -80,7 +80,13 @@ const SERVER_CODES = new Set([
|
||||
"slow_down",
|
||||
"serviceunavailableexception",
|
||||
])
|
||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||
// `invalid_request` is the Vercel AI Gateway's code for an upstream request rejection.
|
||||
const INVALID_REQUEST_CODES = new Set([
|
||||
"invalid_prompt",
|
||||
"invalid_request",
|
||||
"invalid_request_error",
|
||||
"validationexception",
|
||||
])
|
||||
// Azure OpenAI reports `content_filter` with `innererror.code` ResponsibleAIPolicyViolation.
|
||||
// OpenRouter tags provider failures with a typed `error_type`; its Responses skin also
|
||||
// emits `image_content_policy_violation` as the native code.
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly thinking?: BedrockConverse.OptionsInput["thinking"]
|
||||
}
|
||||
export const routes = [BedrockConverse.route]
|
||||
|
||||
@@ -71,6 +72,7 @@ export const model: ProviderPackage.Definition<Settings>["model"] = (modelID, se
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.thinking === undefined ? undefined : { thinking: settings.thinking },
|
||||
profile: settings.profile,
|
||||
region: settings.region,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -32,10 +32,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
|
||||
return result
|
||||
}
|
||||
|
||||
export const gpt5DefaultOptions = (
|
||||
modelID: string,
|
||||
options: { readonly textVerbosity?: boolean } = {},
|
||||
): ProviderOptions | undefined => {
|
||||
export const gpt5DefaultOptions = (modelID: string): ProviderOptions | undefined => {
|
||||
const id = modelID.toLowerCase()
|
||||
if (!id.includes("gpt-5") || id.includes("gpt-5-chat") || id.includes("gpt-5-pro")) return undefined
|
||||
return openAIProviderOptions({
|
||||
@@ -47,27 +44,19 @@ export const gpt5DefaultOptions = (
|
||||
// this, callers using the default model facade get reasoning summaries
|
||||
// they cannot replay statelessly.
|
||||
include: ["reasoning.encrypted_content"],
|
||||
textVerbosity:
|
||||
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
|
||||
? "low"
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export const openAIDefaultOptions = (
|
||||
modelID: string,
|
||||
options: { readonly textVerbosity?: boolean } = {},
|
||||
): ProviderOptions | undefined =>
|
||||
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID, options))
|
||||
export const openAIDefaultOptions = (modelID: string): ProviderOptions | undefined =>
|
||||
mergeProviderOptions(openAIProviderOptions({ store: false }), gpt5DefaultOptions(modelID))
|
||||
|
||||
export const withOpenAIOptions = <Options extends { readonly providerOptions?: OpenAIProviderOptionsInput }>(
|
||||
modelID: string,
|
||||
options: Options,
|
||||
defaults: { readonly textVerbosity?: boolean } = {},
|
||||
): Omit<Options, "providerOptions"> & { readonly providerOptions?: ProviderOptions } => {
|
||||
return {
|
||||
...options,
|
||||
providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID, defaults), options.providerOptions),
|
||||
providerOptions: mergeProviderOptions(openAIDefaultOptions(modelID), options.providerOptions),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ export const configure = (input: Config = {}) => {
|
||||
const modelDefaults = defaults(input)
|
||||
const responses = (id: string | ModelID) =>
|
||||
responsesRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
.with(withOpenAIOptions(id, modelDefaults))
|
||||
.model<OpenAIProviderOptionsInput>({ id })
|
||||
const chat = (id: string | ModelID) =>
|
||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { SystemOne } from "../experimental/system-one.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
|
||||
import { isRecord } from "../protocols/shared.js"
|
||||
import { isRecord, ProviderShared } from "../protocols/shared.js"
|
||||
|
||||
export const id = ProviderID.make("openrouter")
|
||||
const baseURL = "https://openrouter.ai/api/v1"
|
||||
@@ -123,7 +123,7 @@ export const protocol = Protocol.make({
|
||||
return {
|
||||
...body,
|
||||
messages,
|
||||
...bodyOptions(request.providerOptions),
|
||||
...bodyOptions(request.providerOptions, request.generation?.maxTokens),
|
||||
} as OpenRouterBody
|
||||
}),
|
||||
),
|
||||
@@ -143,7 +143,14 @@ const cacheControl = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const bodyOptions = (input: unknown) => {
|
||||
// OpenRouter forwards `reasoning.max_tokens` as the upstream thinking budget. Upstreams such as Anthropic and Alibaba
|
||||
// reject one that is not below the output limit; 1,024 is Anthropic's minimum budget.
|
||||
const fitReasoning = (reasoning: Record<string, unknown>, maxTokens: number | undefined) =>
|
||||
typeof reasoning.max_tokens === "number"
|
||||
? { ...reasoning, max_tokens: ProviderShared.fitThinkingBudget(reasoning.max_tokens, maxTokens, 1_024) }
|
||||
: reasoning
|
||||
|
||||
const bodyOptions = (input: unknown, maxTokens: number | undefined) => {
|
||||
const openrouter = isRecord(input) ? input : {}
|
||||
const { usage, models, provider, plugins, web_search_options, debug, user, reasoning, promptCacheKey, ...options } =
|
||||
openrouter
|
||||
@@ -162,7 +169,7 @@ const bodyOptions = (input: unknown) => {
|
||||
...(isRecord(web_search_options) ? { web_search_options } : {}),
|
||||
...(isRecord(debug) ? { debug } : {}),
|
||||
...(typeof user === "string" ? { user } : {}),
|
||||
...(isRecord(reasoning) ? { reasoning } : {}),
|
||||
...(isRecord(reasoning) ? { reasoning: fitReasoning(reasoning, maxTokens) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,8 @@ import { applyEffortUpdates } from "../effort-updates.js"
|
||||
import { normalizeToolHistory } from "../tool-history.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
import * as ProviderShared from "../protocols/shared.js"
|
||||
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import { ToolSchemaProjection } from "../protocols/utils/tool-schema.js"
|
||||
import type { LanguageModelSanitizerCompatibility, ProtocolID, ProviderOptions } from "../schema/index.js"
|
||||
import {
|
||||
AIError,
|
||||
CompactionResponse,
|
||||
@@ -57,6 +58,7 @@ export interface Route<
|
||||
readonly defaults: RouteDefaults
|
||||
readonly body: RouteBody<Body>
|
||||
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
|
||||
readonly sanitizer?: LanguageModelSanitizerCompatibility
|
||||
readonly with: {
|
||||
<Next extends CompactionOperations | undefined>(
|
||||
patch: RoutePatch<Body, Prepared> & { readonly compact: Next },
|
||||
@@ -388,6 +390,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
defaults: routeInput.defaults ?? {},
|
||||
body: protocol.body,
|
||||
supportsEffortUpdates: protocol.supportsEffortUpdates,
|
||||
sanitizer: protocol.sanitizer,
|
||||
with: (patch: RoutePatch<Body, Prepared>) => {
|
||||
const { compact, id, provider, providerMetadataKey, auth, transport, endpoint, ...defaults } = patch
|
||||
return build({
|
||||
@@ -559,7 +562,9 @@ const prepareRequest = (request: LLMRequest) => {
|
||||
tool.type === "tool" ? tool : { ...tool, tools: dedupe(tool.tools) },
|
||||
)
|
||||
const resolved = applyCachePolicy(
|
||||
applyEffortUpdates(LLMRequest.update(sanitized, { tools: dedupe(sanitized.tools) })),
|
||||
applyEffortUpdates(
|
||||
LLMRequest.update(sanitized, { tools: ToolSchemaProjection.tools(dedupe(sanitized.tools), sanitized.model) }),
|
||||
),
|
||||
)
|
||||
const headers = resolved.model.route.headers?.({ request: resolved })
|
||||
return headers === undefined
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Endpoint } from "./endpoint.js"
|
||||
import { RequestExecutorService, type Interface } from "./executor-service.js"
|
||||
import { RequestExecutor } from "./executor.js"
|
||||
import { MediaProtocol } from "./media-protocol.js"
|
||||
import { Generation, resultEvents, type AwaitOptions, type Observation } from "../generation.js"
|
||||
import { Generation } from "../generation.js"
|
||||
import type { Media } from "../media.js"
|
||||
import {
|
||||
AIError,
|
||||
@@ -52,7 +52,7 @@ export const deployment = (
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** One request, one response. */
|
||||
export interface Route<Request extends MediaRequest, Response> {
|
||||
export interface InlineRoute<Request extends MediaRequest, Response> {
|
||||
readonly kind: "inline"
|
||||
readonly id: string
|
||||
readonly provider: ProviderID
|
||||
@@ -86,7 +86,7 @@ export interface StreamRoute<Request extends MediaRequest, Event, Response> {
|
||||
}
|
||||
|
||||
export type AnyRoute<Request extends MediaRequest, Event, Response> =
|
||||
| Route<Request, Response>
|
||||
| InlineRoute<Request, Response>
|
||||
| StreamRoute<Request, Event, Response>
|
||||
| QueuedRoute<Request, Response>
|
||||
|
||||
@@ -119,7 +119,7 @@ export interface StreamInput<Request extends MediaRequest, Event, Response, Fram
|
||||
*/
|
||||
export const inline = <Request extends MediaRequest, Response>(
|
||||
input: InlineInput<Request, Response>,
|
||||
): Route<Request, Response> => {
|
||||
): InlineRoute<Request, Response> => {
|
||||
const transport = makeTransport(input)
|
||||
return {
|
||||
kind: "inline",
|
||||
@@ -267,59 +267,6 @@ export const stream = <Request extends MediaRequest, Event, Response, Frame, Sta
|
||||
}
|
||||
}
|
||||
|
||||
export const dispatch = <Event, Response>(input: {
|
||||
readonly modality: string
|
||||
readonly execute: Execute
|
||||
readonly responseEvents: (response: Response) => ReadonlyArray<Event>
|
||||
}) => {
|
||||
const notQueued = (route: { readonly provider: ProviderID; readonly id: string }, operation: string) =>
|
||||
new AIError({
|
||||
reason: new UnsupportedOperationError({
|
||||
operation: `${input.modality}.${operation}`,
|
||||
provider: route.provider,
|
||||
route: route.id,
|
||||
message: `${route.provider}/${route.id} is not a queued route; use generate or stream`,
|
||||
}),
|
||||
})
|
||||
const start = <Request extends MediaRequest>(route: AnyRoute<Request, Event, Response>, request: Request) => {
|
||||
if (route.kind !== "queued") return Effect.fail(notQueued(route, "start"))
|
||||
return route.start(request, input.execute)
|
||||
}
|
||||
return {
|
||||
start,
|
||||
resume: <Request extends MediaRequest>(
|
||||
route: AnyRoute<Request, Event, Response>,
|
||||
model: MediaRequest["model"],
|
||||
token: unknown,
|
||||
) => {
|
||||
if (route.kind !== "queued") return Effect.fail(notQueued(route, "resume"))
|
||||
return route.resume(model, token, input.execute)
|
||||
},
|
||||
generate: <Request extends MediaRequest>(
|
||||
route: AnyRoute<Request, Event, Response>,
|
||||
request: Request,
|
||||
options?: AwaitOptions,
|
||||
) => {
|
||||
if (route.kind !== "queued") return route.generate(request, input.execute)
|
||||
return start(route, request).pipe(Effect.flatMap((generation) => generation.await(options)))
|
||||
},
|
||||
stream: <Request extends MediaRequest>(
|
||||
route: AnyRoute<Request, Event, Response>,
|
||||
request: Request,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<Event | Observation, AIError> => {
|
||||
if (route.kind === "stream") return route.stream(request, input.execute)
|
||||
if (route.kind === "queued")
|
||||
return Stream.unwrap(
|
||||
start(route, request).pipe(
|
||||
Effect.map((generation) => resultEvents(generation, input.responseEvents, options)),
|
||||
),
|
||||
)
|
||||
return Stream.fromIterableEffect(Effect.map(route.generate(request, input.execute), input.responseEvents))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Transport plumbing shared by every kind
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Schema, type Effect } from "effect"
|
||||
import type { AIError, LLMEvent, LLMRequest, ProtocolID } from "../schema/index.js"
|
||||
import type { AIError, LanguageModelSanitizerCompatibility, LLMEvent, LLMRequest, ProtocolID } from "../schema/index.js"
|
||||
|
||||
/**
|
||||
* The semantic API contract of one model server family.
|
||||
@@ -43,6 +43,8 @@ export interface Protocol<Body, Frame, Event, State> {
|
||||
readonly stream: ProtocolStream<Frame, Event, State>
|
||||
/** Whether `body.from` lowers `Message.effort(...)` markers; wrappers around another `body.from` must forward it. */
|
||||
readonly supportsEffortUpdates?: (request: LLMRequest) => boolean
|
||||
/** Tool schema sanitizer for every model on this protocol unless the model's compatibility sets one; wrappers around another `body.from` must forward it. */
|
||||
readonly sanitizer?: LanguageModelSanitizerCompatibility
|
||||
}
|
||||
|
||||
export interface ProtocolBody<Body> {
|
||||
|
||||
@@ -1,53 +1,31 @@
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import type { AIError } from "./schema/index.js"
|
||||
import type { SpeechEvent, SpeechOptions, SpeechRequestFor, SpeechResponse } from "./speech.js"
|
||||
import { Context } from "effect"
|
||||
import { MediaClient } from "./media-client.js"
|
||||
import {
|
||||
SpeechTimestampsEvent,
|
||||
SpeechFinishEvent,
|
||||
type SpeechEvent,
|
||||
type SpeechRequestFor,
|
||||
type SpeechResponse,
|
||||
} from "./speech.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly generate: <Options extends SpeechOptions>(
|
||||
request: SpeechRequestFor<Options>,
|
||||
) => Effect.Effect<SpeechResponse, AIError>
|
||||
readonly stream: <Options extends SpeechOptions>(
|
||||
request: SpeechRequestFor<Options>,
|
||||
) => Stream.Stream<SpeechEvent, AIError>
|
||||
}
|
||||
export type Interface = MediaClient.Interface<SpeechRequestFor, SpeechEvent, SpeechResponse>
|
||||
|
||||
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>,
|
||||
): Effect.Effect<SpeechResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request)
|
||||
})
|
||||
|
||||
export const stream = <Options extends SpeechOptions>(
|
||||
request: SpeechRequestFor<Options>,
|
||||
): Stream.Stream<SpeechEvent, AIError, Service> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return client.stream(request)
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return Service.of({
|
||||
generate: (request) => request.model.route.generate(request, executor.execute),
|
||||
stream: (request) => request.model.route.stream(request, executor.execute),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const SpeechClient = {
|
||||
Service,
|
||||
layer,
|
||||
generate,
|
||||
stream,
|
||||
...MediaClient.make(Service, {
|
||||
modality: "speech",
|
||||
responseEvents: (response: SpeechResponse) => [
|
||||
...(response.timestamps === undefined ? [] : [SpeechTimestampsEvent.make({ items: response.timestamps })]),
|
||||
SpeechFinishEvent.make({
|
||||
audio: response.audio,
|
||||
usage: response.usage,
|
||||
notices: response.notices,
|
||||
providerMetadata: response.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
} as const
|
||||
|
||||
+31
-41
@@ -1,8 +1,8 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { ProgressEvent, QueuedEvent } from "./generation.js"
|
||||
import { Media } from "./media.js"
|
||||
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import type { MediaProtocol } from "./route/media-protocol.js"
|
||||
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
|
||||
import { SpeechClient, Service } from "./speech-client.js"
|
||||
|
||||
@@ -10,53 +10,39 @@ import { SpeechClient, Service } from "./speech-client.js"
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SpeechOptions = Record<string, unknown>
|
||||
export type SpeechOptions = MediaModel.Options
|
||||
|
||||
export type SpeechRoute<Options extends SpeechOptions = SpeechOptions> = MediaRoute.StreamRoute<
|
||||
SpeechRequestFor<Options>,
|
||||
SpeechEvent,
|
||||
SpeechResponse
|
||||
>
|
||||
export type SpeechRoute = MediaRoute.AnyRoute<SpeechRequestFor, SpeechEvent, SpeechResponse>
|
||||
|
||||
export class SpeechModel<Options extends SpeechOptions = SpeechOptions> extends MediaModel<
|
||||
SpeechRoute<Options>,
|
||||
Options
|
||||
> {
|
||||
export class SpeechModel<Options extends SpeechOptions = SpeechOptions> extends MediaModel<SpeechRoute, Options> {
|
||||
declare protected readonly _SpeechModel: void
|
||||
|
||||
static make<Options extends SpeechOptions = SpeechOptions>(input: MediaModel.Input<SpeechRoute<Options>>) {
|
||||
return new SpeechModel<Options>(input)
|
||||
}
|
||||
|
||||
/** Compose a streaming speech protocol with its canonical path into a model for one deployment. */
|
||||
static fromRoute<Options extends SpeechOptions = SpeechOptions, Frame = unknown, State = unknown>(
|
||||
route: SpeechModel.RouteInput<Options, Frame, State>,
|
||||
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
|
||||
static fromRoute<Options extends SpeechOptions>(
|
||||
route: MediaModel.InlineRouteInput<SpeechRequestFor<Options>, SpeechResponse>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): SpeechModel<Options>
|
||||
static fromRoute<Options extends SpeechOptions, Frame, State>(
|
||||
route: MediaModel.StreamRouteInput<SpeechRequestFor<Options>, SpeechEvent, Frame, State>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): SpeechModel<Options>
|
||||
static fromRoute<Options extends SpeechOptions, Token>(
|
||||
route: MediaModel.QueuedRouteInput<SpeechRequestFor<Options>, SpeechResponse, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): SpeechModel<Options>
|
||||
static fromRoute<Options extends SpeechOptions, Frame, State, Token>(
|
||||
route: MediaModel.AnyRouteInput<SpeechRequestFor<Options>, SpeechEvent, SpeechResponse, Frame, State, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
) {
|
||||
return new SpeechModel<Options>({
|
||||
id: input.id,
|
||||
provider: route.protocol.provider,
|
||||
http: input.http,
|
||||
route: composeRoute(
|
||||
(composition) => MediaRoute.stream({ ...composition, collect: collectResponse }),
|
||||
route,
|
||||
input,
|
||||
),
|
||||
route: composeRoute(route, input, collectResponse) as SpeechRoute,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace SpeechModel {
|
||||
export type RouteInput<
|
||||
Options extends SpeechOptions = SpeechOptions,
|
||||
Frame = unknown,
|
||||
State = unknown,
|
||||
> = MediaModel.RouteInput<
|
||||
MediaProtocol.Addressed<SpeechRequestFor<Options>>,
|
||||
MediaProtocol.Streamed<SpeechRequestFor<Options>, SpeechEvent, Frame, State>
|
||||
>
|
||||
}
|
||||
|
||||
export const SpeechModelSchema = Schema.declare((value): value is SpeechModel => value instanceof SpeechModel, {
|
||||
expected: "Speech.Model",
|
||||
})
|
||||
@@ -148,11 +134,17 @@ export const SpeechFinishEvent = Schema.Struct({
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "Speech.Event.Finish" })
|
||||
|
||||
const speechEventTagged = Schema.Union([SpeechAudioDeltaEvent, SpeechTimestampsEvent, SpeechFinishEvent]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
const speechEventTagged = Schema.Union([
|
||||
QueuedEvent,
|
||||
ProgressEvent,
|
||||
SpeechAudioDeltaEvent,
|
||||
SpeechTimestampsEvent,
|
||||
SpeechFinishEvent,
|
||||
]).pipe(Schema.toTaggedUnion("type"))
|
||||
export const SpeechEvent = Object.assign(speechEventTagged, {
|
||||
is: {
|
||||
generationQueued: speechEventTagged.guards["generation-queued"],
|
||||
generationProgress: speechEventTagged.guards["generation-progress"],
|
||||
audioDelta: speechEventTagged.guards["audio-delta"],
|
||||
timestamps: speechEventTagged.guards.timestamps,
|
||||
finish: speechEventTagged.guards.finish,
|
||||
@@ -195,17 +187,15 @@ export function request(input: SpeechRequest | SpeechRequestInput) {
|
||||
const requestEffect = (input: SpeechRequest | SpeechRequestInput) => tryRequest(() => request(input))
|
||||
|
||||
export function generate<const Model extends SpeechModel>(
|
||||
input: SpeechRequestInput<Model>,
|
||||
input: SpeechRequest | SpeechRequestInput<Model>,
|
||||
): Effect.Effect<SpeechResponse, AIError, Service>
|
||||
export function generate(input: SpeechRequest): Effect.Effect<SpeechResponse, AIError, Service>
|
||||
export function generate(input: SpeechRequest | SpeechRequestInput) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => SpeechClient.generate(request)))
|
||||
}
|
||||
|
||||
export function stream<const Model extends SpeechModel>(
|
||||
input: SpeechRequestInput<Model>,
|
||||
input: SpeechRequest | SpeechRequestInput<Model>,
|
||||
): Stream.Stream<SpeechEvent, AIError, Service>
|
||||
export function stream(input: SpeechRequest): Stream.Stream<SpeechEvent, AIError, Service>
|
||||
export function stream(input: SpeechRequest | SpeechRequestInput) {
|
||||
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => SpeechClient.stream(request))))
|
||||
}
|
||||
|
||||
@@ -1,34 +1,13 @@
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import type { AwaitOptions, Generation } from "./generation.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import type { AIError } from "./schema/index.js"
|
||||
import { Context } from "effect"
|
||||
import { MediaClient } from "./media-client.js"
|
||||
import {
|
||||
responseEvents,
|
||||
TranscriptionFinishEvent,
|
||||
type TranscriptionEvent,
|
||||
type TranscriptionModel,
|
||||
type TranscriptionOptions,
|
||||
type TranscriptionRequestFor,
|
||||
type TranscriptionResponse,
|
||||
} from "./transcription.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly generate: <Options extends TranscriptionOptions>(
|
||||
request: TranscriptionRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
) => Effect.Effect<TranscriptionResponse, AIError>
|
||||
readonly stream: <Options extends TranscriptionOptions>(
|
||||
request: TranscriptionRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
) => Stream.Stream<TranscriptionEvent, AIError>
|
||||
readonly start: <Options extends TranscriptionOptions>(
|
||||
request: TranscriptionRequestFor<Options>,
|
||||
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
|
||||
readonly resume: <Options extends TranscriptionOptions>(
|
||||
model: TranscriptionModel<Options>,
|
||||
token: unknown,
|
||||
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
|
||||
}
|
||||
export type Interface = MediaClient.Interface<TranscriptionRequestFor, TranscriptionEvent, TranscriptionResponse>
|
||||
|
||||
export class TranscriptionClientService extends Context.Service<TranscriptionClientService, Interface>()(
|
||||
"@opencode/TranscriptionClient",
|
||||
@@ -36,66 +15,10 @@ export class TranscriptionClientService extends Context.Service<TranscriptionCli
|
||||
export const Service = TranscriptionClientService
|
||||
export type Service = TranscriptionClientService
|
||||
|
||||
export const generate = <Options extends TranscriptionOptions>(
|
||||
request: TranscriptionRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<TranscriptionResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request, options)
|
||||
})
|
||||
|
||||
export const stream = <Options extends TranscriptionOptions>(
|
||||
request: TranscriptionRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<TranscriptionEvent, AIError, Service> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return client.stream(request, options)
|
||||
}),
|
||||
)
|
||||
|
||||
export const start = <Options extends TranscriptionOptions>(
|
||||
request: TranscriptionRequestFor<Options>,
|
||||
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.start(request)
|
||||
})
|
||||
|
||||
export const resume = <Options extends TranscriptionOptions>(
|
||||
model: TranscriptionModel<Options>,
|
||||
token: unknown,
|
||||
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.resume(model, token)
|
||||
})
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const dispatch = MediaRoute.dispatch<TranscriptionEvent, TranscriptionResponse>({
|
||||
modality: "transcription",
|
||||
execute: executor.execute,
|
||||
responseEvents,
|
||||
})
|
||||
return Service.of({
|
||||
start: (request) => dispatch.start(request.model.route, request),
|
||||
resume: (model, token) => dispatch.resume(model.route, model, token),
|
||||
generate: (request, options) => dispatch.generate(request.model.route, request, options),
|
||||
stream: (request, options) => dispatch.stream(request.model.route, request, options),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const TranscriptionClient = {
|
||||
Service,
|
||||
layer,
|
||||
generate,
|
||||
stream,
|
||||
start,
|
||||
resume,
|
||||
...MediaClient.make(Service, {
|
||||
modality: "transcription",
|
||||
responseEvents: (response: TranscriptionResponse) => [TranscriptionFinishEvent.make({ ...response })],
|
||||
}),
|
||||
} as const
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./generation.js"
|
||||
import { Media } from "./media.js"
|
||||
import { MediaModel, composeAnyRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import type { MediaProtocol } from "./route/media-protocol.js"
|
||||
import { AIError, HttpOptions, MediaUsage, ProviderMetadata } from "./schema/index.js"
|
||||
import { TranscriptionClient, Service } from "./transcription-client.js"
|
||||
|
||||
@@ -11,90 +10,49 @@ import { TranscriptionClient, Service } from "./transcription-client.js"
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type TranscriptionOptions = Record<string, unknown>
|
||||
export type TranscriptionOptions = MediaModel.Options
|
||||
|
||||
export type TranscriptionRoute<Options extends TranscriptionOptions = TranscriptionOptions> = MediaRoute.AnyRoute<
|
||||
TranscriptionRequestFor<Options>,
|
||||
TranscriptionEvent,
|
||||
TranscriptionResponse
|
||||
>
|
||||
export type TranscriptionRoute = MediaRoute.AnyRoute<TranscriptionRequestFor, TranscriptionEvent, TranscriptionResponse>
|
||||
|
||||
export class TranscriptionModel<Options extends TranscriptionOptions = TranscriptionOptions> extends MediaModel<
|
||||
TranscriptionRoute<Options>,
|
||||
TranscriptionRoute,
|
||||
Options
|
||||
> {
|
||||
declare protected readonly _TranscriptionModel: void
|
||||
|
||||
static make<Options extends TranscriptionOptions = TranscriptionOptions>(
|
||||
input: MediaModel.Input<TranscriptionRoute<Options>>,
|
||||
) {
|
||||
return new TranscriptionModel<Options>(input)
|
||||
}
|
||||
|
||||
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
|
||||
static fromRoute<Options extends TranscriptionOptions>(
|
||||
route: TranscriptionModel.InlineRouteInput<Options>,
|
||||
route: MediaModel.InlineRouteInput<TranscriptionRequestFor<Options>, TranscriptionResponse>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): TranscriptionModel<Options>
|
||||
static fromRoute<Options extends TranscriptionOptions, Frame, State>(
|
||||
route: TranscriptionModel.StreamRouteInput<Options, Frame, State>,
|
||||
route: MediaModel.StreamRouteInput<TranscriptionRequestFor<Options>, TranscriptionEvent, Frame, State>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): TranscriptionModel<Options>
|
||||
static fromRoute<Options extends TranscriptionOptions, Token>(
|
||||
route: TranscriptionModel.QueuedRouteInput<Options, Token>,
|
||||
route: MediaModel.QueuedRouteInput<TranscriptionRequestFor<Options>, TranscriptionResponse, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): TranscriptionModel<Options>
|
||||
static fromRoute<Options extends TranscriptionOptions, Frame, State, Token>(
|
||||
route: TranscriptionModel.RouteInput<Options, Frame, State, Token>,
|
||||
route: MediaModel.AnyRouteInput<
|
||||
TranscriptionRequestFor<Options>,
|
||||
TranscriptionEvent,
|
||||
TranscriptionResponse,
|
||||
Frame,
|
||||
State,
|
||||
Token
|
||||
>,
|
||||
input: MediaRoute.ModelInput,
|
||||
) {
|
||||
return new TranscriptionModel<Options>({
|
||||
id: input.id,
|
||||
provider: route.protocol.provider,
|
||||
http: input.http,
|
||||
route: composeAnyRoute(route, input, collectResponse),
|
||||
route: composeRoute(route, input, collectResponse) as TranscriptionRoute,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace TranscriptionModel {
|
||||
export type InlineRouteInput<Options extends TranscriptionOptions = TranscriptionOptions> = MediaModel.RouteInput<
|
||||
TranscriptionRequestFor<Options>,
|
||||
MediaProtocol.Inline<TranscriptionRequestFor<Options>, TranscriptionResponse>
|
||||
>
|
||||
|
||||
export type StreamRouteInput<
|
||||
Options extends TranscriptionOptions = TranscriptionOptions,
|
||||
Frame = unknown,
|
||||
State = unknown,
|
||||
> = MediaModel.RouteInput<
|
||||
MediaProtocol.Addressed<TranscriptionRequestFor<Options>>,
|
||||
MediaProtocol.Streamed<TranscriptionRequestFor<Options>, TranscriptionEvent, Frame, State>
|
||||
>
|
||||
|
||||
export type QueuedRouteInput<
|
||||
Options extends TranscriptionOptions = TranscriptionOptions,
|
||||
Token = unknown,
|
||||
> = MediaModel.RouteInput<
|
||||
TranscriptionRequestFor<Options>,
|
||||
MediaProtocol.Queued<TranscriptionRequestFor<Options>, TranscriptionResponse, Token>
|
||||
>
|
||||
|
||||
export type RouteInput<
|
||||
Options extends TranscriptionOptions = TranscriptionOptions,
|
||||
Frame = unknown,
|
||||
State = unknown,
|
||||
Token = unknown,
|
||||
> = MediaModel.AnyRouteInput<
|
||||
TranscriptionRequestFor<Options>,
|
||||
TranscriptionEvent,
|
||||
TranscriptionResponse,
|
||||
Frame,
|
||||
State,
|
||||
Token
|
||||
>
|
||||
}
|
||||
|
||||
export const TranscriptionModelSchema = Schema.declare(
|
||||
(value): value is TranscriptionModel => value instanceof TranscriptionModel,
|
||||
{ expected: "Transcription.Model" },
|
||||
@@ -212,10 +170,6 @@ export const TranscriptionEvent = Object.assign(transcriptionEventTagged, {
|
||||
})
|
||||
export type TranscriptionEvent = Schema.Schema.Type<typeof transcriptionEventTagged>
|
||||
|
||||
export const responseEvents = (response: TranscriptionResponse): ReadonlyArray<TranscriptionEvent> => [
|
||||
TranscriptionFinishEvent.make({ ...response }),
|
||||
]
|
||||
|
||||
const collectResponse = (events: ReadonlyArray<TranscriptionEvent>): Effect.Effect<TranscriptionResponse> => {
|
||||
const finish = events.find(TranscriptionEvent.is.finish)
|
||||
// Every transcription protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
|
||||
@@ -243,11 +197,7 @@ export function request(input: TranscriptionRequest | TranscriptionRequestInput)
|
||||
const requestEffect = (input: TranscriptionRequest | TranscriptionRequestInput) => tryRequest(() => request(input))
|
||||
|
||||
export function generate<const Model extends TranscriptionModel>(
|
||||
input: TranscriptionRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<TranscriptionResponse, AIError, Service>
|
||||
export function generate(
|
||||
input: TranscriptionRequest,
|
||||
input: TranscriptionRequest | TranscriptionRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<TranscriptionResponse, AIError, Service>
|
||||
export function generate(input: TranscriptionRequest | TranscriptionRequestInput, options?: AwaitOptions) {
|
||||
@@ -255,11 +205,7 @@ export function generate(input: TranscriptionRequest | TranscriptionRequestInput
|
||||
}
|
||||
|
||||
export function stream<const Model extends TranscriptionModel>(
|
||||
input: TranscriptionRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<TranscriptionEvent, AIError, Service>
|
||||
export function stream(
|
||||
input: TranscriptionRequest,
|
||||
input: TranscriptionRequest | TranscriptionRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<TranscriptionEvent, AIError, Service>
|
||||
export function stream(input: TranscriptionRequest | TranscriptionRequestInput, options?: AwaitOptions) {
|
||||
@@ -268,15 +214,14 @@ export function stream(input: TranscriptionRequest | TranscriptionRequestInput,
|
||||
|
||||
/** Inline and streaming routes fail with `UnsupportedOperation`. */
|
||||
export function start<const Model extends TranscriptionModel>(
|
||||
input: TranscriptionRequestInput<Model>,
|
||||
input: TranscriptionRequest | TranscriptionRequestInput<Model>,
|
||||
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service>
|
||||
export function start(input: TranscriptionRequest): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service>
|
||||
export function start(input: TranscriptionRequest | TranscriptionRequestInput) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => TranscriptionClient.start(request)))
|
||||
}
|
||||
|
||||
export const resume = <Options extends TranscriptionOptions>(
|
||||
model: TranscriptionModel<Options>,
|
||||
export const resume = (
|
||||
model: TranscriptionModel,
|
||||
token: unknown,
|
||||
): Effect.Effect<Generation<TranscriptionResponse>, AIError, Service> => TranscriptionClient.resume(model, token)
|
||||
|
||||
|
||||
@@ -1,98 +1,30 @@
|
||||
import { Context, Effect, Layer, Stream } from "effect"
|
||||
import { resultEvents, type AwaitOptions, type Generation } from "./generation.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import type { AIError } from "./schema/index.js"
|
||||
import { Context } from "effect"
|
||||
import { MediaClient } from "./media-client.js"
|
||||
import {
|
||||
responseEvents,
|
||||
VideoOutputEvent,
|
||||
VideoFinishEvent,
|
||||
type VideoEvent,
|
||||
type VideoModel,
|
||||
type VideoOptions,
|
||||
type VideoRequestFor,
|
||||
type VideoResponse,
|
||||
} from "./video.js"
|
||||
|
||||
export interface Interface {
|
||||
readonly start: <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
) => Effect.Effect<Generation<VideoResponse>, AIError>
|
||||
readonly resume: <Options extends VideoOptions>(
|
||||
model: VideoModel<Options>,
|
||||
token: unknown,
|
||||
) => Effect.Effect<Generation<VideoResponse>, AIError>
|
||||
readonly generate: <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
) => Effect.Effect<VideoResponse, AIError>
|
||||
readonly stream: <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
) => Stream.Stream<VideoEvent, AIError>
|
||||
}
|
||||
export type Interface = MediaClient.Interface<VideoRequestFor, VideoEvent, VideoResponse>
|
||||
|
||||
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>,
|
||||
): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.start(request)
|
||||
})
|
||||
|
||||
export const resume = <Options extends VideoOptions>(
|
||||
model: VideoModel<Options>,
|
||||
token: unknown,
|
||||
): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.resume(model, token)
|
||||
})
|
||||
|
||||
export const generate = <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<VideoResponse, AIError, Service> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request, options)
|
||||
})
|
||||
|
||||
export const stream = <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<VideoEvent, AIError, Service> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return client.stream(request, options)
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const start = <Options extends VideoOptions>(request: VideoRequestFor<Options>) =>
|
||||
request.model.route.start(request, executor.execute)
|
||||
return Service.of({
|
||||
start,
|
||||
resume: (model, token) => model.route.resume(model, token, executor.execute),
|
||||
generate: (request, options) => start(request).pipe(Effect.flatMap((generation) => generation.await(options))),
|
||||
stream: (request, options) =>
|
||||
Stream.unwrap(
|
||||
start(request).pipe(Effect.map((generation) => resultEvents(generation, responseEvents, options))),
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const VideoClient = {
|
||||
Service,
|
||||
layer,
|
||||
start,
|
||||
resume,
|
||||
generate,
|
||||
stream,
|
||||
...MediaClient.make(Service, {
|
||||
modality: "video",
|
||||
responseEvents: (response: VideoResponse) => [
|
||||
...response.videos.map((video, index) => VideoOutputEvent.make({ index, video })),
|
||||
VideoFinishEvent.make({
|
||||
usage: response.usage,
|
||||
notices: response.notices,
|
||||
providerMetadata: response.providerMetadata,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
} as const
|
||||
|
||||
+37
-41
@@ -3,7 +3,6 @@ import { Generation, ProgressEvent, QueuedEvent, type AwaitOptions } from "./gen
|
||||
import { Media } from "./media.js"
|
||||
import { MediaModel, composeRoute, tryRequest } from "./media-model.js"
|
||||
import { MediaRoute } from "./route/media.js"
|
||||
import type { MediaProtocol } from "./route/media-protocol.js"
|
||||
import { AIError, HttpOptions, MediaUsage, ProviderMetadata, type OpenString } from "./schema/index.js"
|
||||
import { VideoClient, Service } from "./video-client.js"
|
||||
|
||||
@@ -11,41 +10,39 @@ import { VideoClient, Service } from "./video-client.js"
|
||||
// Model
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type VideoOptions = Record<string, unknown>
|
||||
export type VideoOptions = MediaModel.Options
|
||||
|
||||
export type VideoRoute<Options extends VideoOptions = VideoOptions> = MediaRoute.QueuedRoute<
|
||||
VideoRequestFor<Options>,
|
||||
VideoResponse
|
||||
>
|
||||
export type VideoRoute = MediaRoute.AnyRoute<VideoRequestFor, VideoEvent, VideoResponse>
|
||||
|
||||
export class VideoModel<Options extends VideoOptions = VideoOptions> extends MediaModel<VideoRoute<Options>, Options> {
|
||||
export class VideoModel<Options extends VideoOptions = VideoOptions> extends MediaModel<VideoRoute, Options> {
|
||||
declare protected readonly _VideoModel: void
|
||||
|
||||
static make<Options extends VideoOptions = VideoOptions>(input: MediaModel.Input<VideoRoute<Options>>) {
|
||||
return new VideoModel<Options>(input)
|
||||
}
|
||||
|
||||
/** Compose a queued video protocol with its canonical start path into a model for one deployment. */
|
||||
static fromRoute<Options extends VideoOptions = VideoOptions, Token = unknown>(
|
||||
route: VideoModel.RouteInput<Options, Token>,
|
||||
/** The number of type arguments selects the kind: `<Options>`, `<Options, Frame, State>`, or `<Options, Token>`. */
|
||||
static fromRoute<Options extends VideoOptions>(
|
||||
route: MediaModel.InlineRouteInput<VideoRequestFor<Options>, VideoResponse>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): VideoModel<Options>
|
||||
static fromRoute<Options extends VideoOptions, Frame, State>(
|
||||
route: MediaModel.StreamRouteInput<VideoRequestFor<Options>, VideoEvent, Frame, State>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): VideoModel<Options>
|
||||
static fromRoute<Options extends VideoOptions, Token>(
|
||||
route: MediaModel.QueuedRouteInput<VideoRequestFor<Options>, VideoResponse, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
): VideoModel<Options>
|
||||
static fromRoute<Options extends VideoOptions, Frame, State, Token>(
|
||||
route: MediaModel.AnyRouteInput<VideoRequestFor<Options>, VideoEvent, VideoResponse, Frame, State, Token>,
|
||||
input: MediaRoute.ModelInput,
|
||||
) {
|
||||
return new VideoModel<Options>({
|
||||
id: input.id,
|
||||
provider: route.protocol.provider,
|
||||
http: input.http,
|
||||
route: composeRoute(MediaRoute.queued, route, input),
|
||||
route: composeRoute(route, input, collectResponse) as VideoRoute,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace VideoModel {
|
||||
export type RouteInput<Options extends VideoOptions = VideoOptions, Token = unknown> = MediaModel.RouteInput<
|
||||
VideoRequestFor<Options>,
|
||||
MediaProtocol.Queued<VideoRequestFor<Options>, VideoResponse, Token>
|
||||
>
|
||||
}
|
||||
|
||||
export const VideoModelSchema = Schema.declare((value): value is VideoModel => value instanceof VideoModel, {
|
||||
expected: "Video.Model",
|
||||
})
|
||||
@@ -149,15 +146,19 @@ export const VideoEvent = Object.assign(videoEventTagged, {
|
||||
})
|
||||
export type VideoEvent = Schema.Schema.Type<typeof videoEventTagged>
|
||||
|
||||
/** A completed response expanded into the streaming event shape. */
|
||||
export const responseEvents = (response: VideoResponse): ReadonlyArray<VideoEvent> => [
|
||||
...response.videos.map((video, index) => VideoOutputEvent.make({ index, video })),
|
||||
VideoFinishEvent.make({
|
||||
usage: response.usage,
|
||||
notices: response.notices,
|
||||
providerMetadata: response.providerMetadata,
|
||||
}),
|
||||
]
|
||||
const collectResponse = (events: ReadonlyArray<VideoEvent>): Effect.Effect<VideoResponse> => {
|
||||
const finish = events.find(VideoEvent.is.finish)
|
||||
// A streaming video protocol's `finish` emits the terminal event or fails, so a completed stream always has one.
|
||||
if (finish === undefined) return Effect.die(new Error("The video stream completed without a finish event"))
|
||||
return Effect.succeed(
|
||||
new VideoResponse({
|
||||
videos: events.filter(VideoEvent.is.video).map((event) => event.video),
|
||||
usage: finish.usage,
|
||||
notices: finish.notices,
|
||||
providerMetadata: finish.providerMetadata,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Request-shaped call API
|
||||
@@ -178,33 +179,28 @@ export function request(input: VideoRequest | VideoRequestInput) {
|
||||
const requestEffect = (input: VideoRequest | VideoRequestInput) => tryRequest(() => request(input))
|
||||
|
||||
export function start<const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model>,
|
||||
input: VideoRequest | VideoRequestInput<Model>,
|
||||
): Effect.Effect<Generation<VideoResponse>, AIError, Service>
|
||||
export function start(input: VideoRequest): Effect.Effect<Generation<VideoResponse>, AIError, Service>
|
||||
export function start(input: VideoRequest | VideoRequestInput) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => VideoClient.start(request)))
|
||||
}
|
||||
|
||||
export function generate<const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model>,
|
||||
input: VideoRequest | VideoRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Effect.Effect<VideoResponse, AIError, Service>
|
||||
export function generate(input: VideoRequest, options?: AwaitOptions): Effect.Effect<VideoResponse, AIError, Service>
|
||||
export function generate(input: VideoRequest | VideoRequestInput, options?: AwaitOptions) {
|
||||
return requestEffect(input).pipe(Effect.flatMap((request) => VideoClient.generate(request, options)))
|
||||
}
|
||||
|
||||
/** Rebuild a generation handle from a persisted `Generation.token`, refreshing its status once. */
|
||||
export const resume = <Options extends VideoOptions>(
|
||||
model: VideoModel<Options>,
|
||||
token: unknown,
|
||||
): Effect.Effect<Generation<VideoResponse>, AIError, Service> => VideoClient.resume(model, token)
|
||||
export const resume = (model: VideoModel, token: unknown): Effect.Effect<Generation<VideoResponse>, AIError, Service> =>
|
||||
VideoClient.resume(model, token)
|
||||
|
||||
export function stream<const Model extends VideoModel>(
|
||||
input: VideoRequestInput<Model>,
|
||||
input: VideoRequest | VideoRequestInput<Model>,
|
||||
options?: AwaitOptions,
|
||||
): Stream.Stream<VideoEvent, AIError, Service>
|
||||
export function stream(input: VideoRequest, options?: AwaitOptions): Stream.Stream<VideoEvent, AIError, Service>
|
||||
export function stream(input: VideoRequest | VideoRequestInput, options?: AwaitOptions) {
|
||||
return Stream.unwrap(requestEffect(input).pipe(Effect.map((request) => VideoClient.stream(request, options))))
|
||||
}
|
||||
|
||||
@@ -54,6 +54,19 @@ import { TestLLM } from "@opencode/ai/testing"
|
||||
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
|
||||
|
||||
describe("public exports", () => {
|
||||
test("modality, provider, and protocol entrypoints load first in a fresh process", async () => {
|
||||
const results = await Promise.all(
|
||||
["image", "video", "speech", "transcription", "providers", "protocols"].map(async (entry) => {
|
||||
const child = Bun.spawn(
|
||||
[process.execPath, "-e", `await import(${JSON.stringify(`${import.meta.dir}/../src/${entry}.ts`)})`],
|
||||
{ stderr: "pipe" },
|
||||
)
|
||||
return { entry, exitCode: await child.exited, stderr: await new Response(child.stderr).text() }
|
||||
}),
|
||||
)
|
||||
expect(results.filter((result) => result.exitCode !== 0)).toEqual([])
|
||||
})
|
||||
|
||||
test("root exposes app-facing runtime APIs", () => {
|
||||
expect(LLM.request).toBeFunction()
|
||||
expect(LLMClient.Service).toBeFunction()
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":50}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":50}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
@@ -525,7 +525,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_hejtTYDa1IfLyNIzb3fq9gJs\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":50,\"previous_response_id\":\"resp_0d9a44b6df400533016aa8c8e36de887d1be260913d131b2ca\"}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"function_call_output\",\"call_id\":\"call_hejtTYDa1IfLyNIzb3fq9gJs\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Call get_weather once, then reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":50,\"previous_response_id\":\"resp_0d9a44b6df400533016aa8c8e36de887d1be260913d131b2ca\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
@@ -109,7 +109,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a6277dd90b94da1016aa8c946e33487d1b725d8e9dc874d82\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMlHtG6yltzgW_UjUcYxvl2hoMkk7cSEH5SJMe9CR5gKIaCwCh4peUo8XZrd-EU-tPCXthv7JzHxYXGVG1fIgTJ7BjBoOh-jgd_oGlyCHj2jVPj7nVoB763tZHdyw_ovL3V7GpJ6VeLGIsRGqNTnBz47ZuKikTKrbTDupn6fT2wOM_69zwdg4-UVnoF2J9E_jIS0E5XRtRwOidqANl61HCwo7LV-Ut1aqCbXb-59vkrVGWPxD_8n4smf7Qjywc0FCH9zwuEDX4cPdqj3MmjvYPrn0jand9LKkAy9rblaKFJSQfVuritbHrdQrnF7hLxu7QCpehOzNXOkpNEqTmXnAZjfMc53hq-ahH6_KoyYlZpompwyGPngFeI2fKRqP8rlpDilD1BHBsh-bl7kXzI8HQ_jameXwPZ1La6gjtFkThXp53BD6BOx11SB9Nypqolu2at5rR32UYcGrYeGtTu-HmYGp0oHVFkHumTNuHKmGXV14dI9swgryfygLhX8EAJrWrjm3e8rvAkHKpAZ0IkmlCcp15UCFDKNeS560fQVRaKXWnQ7m0Ih3C1xG0ifJ89j27c8GHo1kAhEJk-lSB1-FZr9Ls_w7N772kmZ2a7LLswu3kNW78kPas_CtcOnBOHwE1DhcVh5YpxwNftOHZnK1v8NPNF8EWqio4ArZy1thMCzH5zXBNcFzgd8tePMFukBblSP8QGQoVYRqTne2sFoOZXjslXnDvEe-Ycj8X38zWgRiwAk7guOloFC7Se36KCDP357773Vah86gWCt55mSEyhVW_GF1oTuHvJ18GZsXcyN21scF1PSr8YaCM_jR7ZkU1GYXbQK2Y4oTAV9XDptQA5YzEREnn7muC_6v5ZTAglZF1lhn9Q0NwmylZEAXJdSGHaqXt1Hv-vQlprA_9m22vrreBOTLPnVK946J8absKrwfe-jK_1n_9YQR43uwH8XwFBFND0c4lICCQGbxwM8pX4ACWR0c19aORCYm-M5FrJsxmG29_aDVNhcvkoQ3mlP7ITQeqzkrjfytSwLb2BYpXYZKjEHNfV9j3JoxJobUkK5hrxXBhTvZzbVBnE0LSXQNwR-JAcOliP_jXBEeQ-28B-aGW8TI1vEP2i260QKgzOzPC-pOoFp0-vCvxojNO0kE16ECVTLfDNLNGSMZs9vekdLJP2akBp6PsUXsQUTbWO_wr1E2oNU7ctfMRxoh-yP0ZW2_xz_NjE72O5LF_6zuDVV7Q==\"},{\"type\":\"message\",\"id\":\"msg_0a6277dd90b94da1016aa8c947253887d184c150fcbcbcd8da\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Alpha.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_0a6277dd90b94da1016aa8c946e33487d1b725d8e9dc874d82\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMlHtG6yltzgW_UjUcYxvl2hoMkk7cSEH5SJMe9CR5gKIaCwCh4peUo8XZrd-EU-tPCXthv7JzHxYXGVG1fIgTJ7BjBoOh-jgd_oGlyCHj2jVPj7nVoB763tZHdyw_ovL3V7GpJ6VeLGIsRGqNTnBz47ZuKikTKrbTDupn6fT2wOM_69zwdg4-UVnoF2J9E_jIS0E5XRtRwOidqANl61HCwo7LV-Ut1aqCbXb-59vkrVGWPxD_8n4smf7Qjywc0FCH9zwuEDX4cPdqj3MmjvYPrn0jand9LKkAy9rblaKFJSQfVuritbHrdQrnF7hLxu7QCpehOzNXOkpNEqTmXnAZjfMc53hq-ahH6_KoyYlZpompwyGPngFeI2fKRqP8rlpDilD1BHBsh-bl7kXzI8HQ_jameXwPZ1La6gjtFkThXp53BD6BOx11SB9Nypqolu2at5rR32UYcGrYeGtTu-HmYGp0oHVFkHumTNuHKmGXV14dI9swgryfygLhX8EAJrWrjm3e8rvAkHKpAZ0IkmlCcp15UCFDKNeS560fQVRaKXWnQ7m0Ih3C1xG0ifJ89j27c8GHo1kAhEJk-lSB1-FZr9Ls_w7N772kmZ2a7LLswu3kNW78kPas_CtcOnBOHwE1DhcVh5YpxwNftOHZnK1v8NPNF8EWqio4ArZy1thMCzH5zXBNcFzgd8tePMFukBblSP8QGQoVYRqTne2sFoOZXjslXnDvEe-Ycj8X38zWgRiwAk7guOloFC7Se36KCDP357773Vah86gWCt55mSEyhVW_GF1oTuHvJ18GZsXcyN21scF1PSr8YaCM_jR7ZkU1GYXbQK2Y4oTAV9XDptQA5YzEREnn7muC_6v5ZTAglZF1lhn9Q0NwmylZEAXJdSGHaqXt1Hv-vQlprA_9m22vrreBOTLPnVK946J8absKrwfe-jK_1n_9YQR43uwH8XwFBFND0c4lICCQGbxwM8pX4ACWR0c19aORCYm-M5FrJsxmG29_aDVNhcvkoQ3mlP7ITQeqzkrjfytSwLb2BYpXYZKjEHNfV9j3JoxJobUkK5hrxXBhTvZzbVBnE0LSXQNwR-JAcOliP_jXBEeQ-28B-aGW8TI1vEP2i260QKgzOzPC-pOoFp0-vCvxojNO0kE16ECVTLfDNLNGSMZs9vekdLJP2akBp6PsUXsQUTbWO_wr1E2oNU7ctfMRxoh-yP0ZW2_xz_NjE72O5LF_6zuDVV7Q==\"},{\"type\":\"message\",\"id\":\"msg_0a6277dd90b94da1016aa8c947253887d184c150fcbcbcd8da\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Alpha.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Beta.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+3
-3
@@ -30,7 +30,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
@@ -109,7 +109,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30,\"previous_response_id\":\"resp_01cc0cda24c36acf016aa8ca3c1d3c87d1853283f43675e411\"}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30,\"previous_response_id\":\"resp_01cc0cda24c36acf016aa8ca3c1d3c87d1853283f43675e411\"}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
@@ -119,7 +119,7 @@
|
||||
{
|
||||
"direction": "client",
|
||||
"kind": "text",
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_01cc0cda24c36acf016aa8ca3ced8c87d1a14c5c6a2ced8544\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMo9M9B15LsV1CLsXpNJpI08GiwCAK97hTheQ2s1lAkKgARMs1HIIXVeAr-tbUq88yN51fiQE8HvGwdF9ZcNI6bZle8D2PS7m8O7UE7C1Z_HX_fz2f0pRHyRfWpVAT2gYQWIDeZu6UdgxbKPBW0lXWzlk_relHG5x6nXkYzZoEeVasqavWoyMMSX7cexe-IYJh6e_3DgRpOchueS8Z-70P0w5R83Ea7UXZQNhMA0yDEYu_td2PmE2Pd2PUTOB3mxF2pb1z7-2t6S0UryhHx0az7Gh2eT60GGUqz9CIZzNE_FX--tszeuO0eI92Cen5tirOUHBTyyDqE0eG26DRl_p_U-xDZwaQONbtYbvkvrj-G7FA2oZXxjJPHuQZsNgBslXS-H0KT1lx3Y8XJ9QMVjFLFaucFG64wCmXPfCH8dYtX_YqfYQR4lwNfiSbyJEX2oDvTVVD_aCJ9NRo7c0aCTtmKBvr6fvvAy3MAFxAp_Sm2nMx4P5GYO4qAmJDByywKw-VK1vHlv3NRmVsAgbArIFgm-axoCs2PLpvZjDqeQGPavaq8zKWTyZYqBsEzKZUtGOZfYjD4mud0Z08I4i2H4K-L00ccVauode3548ZipOIuslbhJxonQXsF6TFdW2Hj8E5JjoEr5IbmwHyI0PBcDWW5AmkjHLwr9v08mFppRoD-2wzPAd5igROAuUbvJhiQd2A-uOaohwMjdpFxrjyUqgGTlI5g7tmI1ceeQWms0bKm8Pd0wIqVM3Nq6YvV7XfEyeogfRMVUQezr_lES42ZMVAoKBSFzMysDwCFkhVNVclcTUpcUUbVp21FChG7Ag-xuq8Cl6OGLA8nWX1C0aCf2HNa-n3dkYr1DtUziurh1MD-UIs5jdGiq3ptrc0VaVZwNdD4jVfAoHB_Ws7GiISXuclfpqsG3DTJEfzlbukI1vxXrt3FArsHiQvQjW5UM7gGel32M6p8AlXRxnez9PgIuU1WrtBUJetk7m39AZwp_aqbqC-AJ-MF70xP1VJZwFN-GeNL3VZsRHePFG4h7Pj---CCZRGlmzuE1-b-sIE7Bn_gbue_qHFMZJhAY55MO25vfZbSoLWHZCGmLMjJVHOYCPoy6l7zyxNmoIcC58QILNWal31KLCDmCsASmZC-xQRjyFwt-kvLbvk38Dc02IKcP3ujhf6WRRr1A0hh1K-gXmv_XI_MUFDcOVIjjhB-rXgjWSCoKfaKI9GCIsb9mnNaBq65BWg==\"},{\"type\":\"message\",\"id\":\"msg_01cc0cda24c36acf016aa8ca3d4a0087d1950121aed4802434\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":30}"
|
||||
"body": "{\"type\":\"response.create\",\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Ready.\"}]},{\"type\":\"reasoning\",\"id\":\"rs_01cc0cda24c36acf016aa8ca3ced8c87d1a14c5c6a2ced8544\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMo9M9B15LsV1CLsXpNJpI08GiwCAK97hTheQ2s1lAkKgARMs1HIIXVeAr-tbUq88yN51fiQE8HvGwdF9ZcNI6bZle8D2PS7m8O7UE7C1Z_HX_fz2f0pRHyRfWpVAT2gYQWIDeZu6UdgxbKPBW0lXWzlk_relHG5x6nXkYzZoEeVasqavWoyMMSX7cexe-IYJh6e_3DgRpOchueS8Z-70P0w5R83Ea7UXZQNhMA0yDEYu_td2PmE2Pd2PUTOB3mxF2pb1z7-2t6S0UryhHx0az7Gh2eT60GGUqz9CIZzNE_FX--tszeuO0eI92Cen5tirOUHBTyyDqE0eG26DRl_p_U-xDZwaQONbtYbvkvrj-G7FA2oZXxjJPHuQZsNgBslXS-H0KT1lx3Y8XJ9QMVjFLFaucFG64wCmXPfCH8dYtX_YqfYQR4lwNfiSbyJEX2oDvTVVD_aCJ9NRo7c0aCTtmKBvr6fvvAy3MAFxAp_Sm2nMx4P5GYO4qAmJDByywKw-VK1vHlv3NRmVsAgbArIFgm-axoCs2PLpvZjDqeQGPavaq8zKWTyZYqBsEzKZUtGOZfYjD4mud0Z08I4i2H4K-L00ccVauode3548ZipOIuslbhJxonQXsF6TFdW2Hj8E5JjoEr5IbmwHyI0PBcDWW5AmkjHLwr9v08mFppRoD-2wzPAd5igROAuUbvJhiQd2A-uOaohwMjdpFxrjyUqgGTlI5g7tmI1ceeQWms0bKm8Pd0wIqVM3Nq6YvV7XfEyeogfRMVUQezr_lES42ZMVAoKBSFzMysDwCFkhVNVclcTUpcUUbVp21FChG7Ag-xuq8Cl6OGLA8nWX1C0aCf2HNa-n3dkYr1DtUziurh1MD-UIs5jdGiq3ptrc0VaVZwNdD4jVfAoHB_Ws7GiISXuclfpqsG3DTJEfzlbukI1vxXrt3FArsHiQvQjW5UM7gGel32M6p8AlXRxnez9PgIuU1WrtBUJetk7m39AZwp_aqbqC-AJ-MF70xP1VJZwFN-GeNL3VZsRHePFG4h7Pj---CCZRGlmzuE1-b-sIE7Bn_gbue_qHFMZJhAY55MO25vfZbSoLWHZCGmLMjJVHOYCPoy6l7zyxNmoIcC58QILNWal31KLCDmCsASmZC-xQRjyFwt-kvLbvk38Dc02IKcP3ujhf6WRRr1A0hh1K-gXmv_XI_MUFDcOVIjjhB-rXgjWSCoKfaKI9GCIsb9mnNaBq65BWg==\"},{\"type\":\"message\",\"id\":\"msg_01cc0cda24c36acf016aa8ca3d4a0087d1950121aed4802434\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Ready.\"}],\"phase\":\"final_answer\",\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Reply exactly: Recovered.\"}]}],\"instructions\":\"Follow the user's exact reply instruction.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":30}"
|
||||
},
|
||||
{
|
||||
"direction": "server",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
@@ -26,7 +26,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"max_output_tokens\":120,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
@@ -44,7 +44,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_052e7ec551f55289016aa8c8d63eac87d19d6b921611f123e7\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMjWZ2Eei8_Gf-5FeEFAYp-gSzFL4D4lQBKL_fyyTYXv5iJ-2jql1mOq0wZpqHL8O9MWxebQGW56Ahd-p21qrDD52CyUBqKKlF87eC1d-cTgjXQlFMsPxvwyQeuU6A2l8tanTtJ48sKtzZtHrDuBXZ35u-lONnovjFGMX3Q83xoqG_um_w5rT420TA_SyU4fGt7oiQvOPS1q4PNo97O824oRnI7n_BC1jPYCaJhl2I1rPJg4afuOpjG-u7JcXRD4JPwZdqMa5o2d0uDKHuUYwP25qiPKKDqTTqFka5cDJjZNPF3ZkVHR-cagjZGvMnizXXxgUpPJ9j83gqY4QJLKCkzcaBj9H7mAL-v9yl4I5kn_9_DhpMILs2SZkC8AvIYNgmel3sDV_BG4XZ2JXciZz86ukQ6DwXgQqS4HOB91g-sGHOWMU1ohsZlEvvJBjGkJ_rAdXVMqAbvi2zvE3_NI4sTAGUrIugGJePrQYTe8gqL8f9NsYac6pzHNQL1e_jQNUvp49bu7EsPzCP3KPYVvZCFohDdwe7sMd6wrztrCJwM4CLdAQK61A7sYzU0HyglLtPidmS5QkSmV6U_xgih7JbKnY0oAeCyYw4ADYqdNTi0axmBErh-lbh-XKNG_TnoMa-2IS3X041N8OsDfSdQp3QsAm53fF8seQuLFa27Iaq2etMj3yGeqWVjA-Mae3K34mt2YPGjQ-HbIOMVmYXBLzNr-s2fT35Sp6SDEsFyvzXb0Vij58s1wW5zkuKgaJiroGgkY86NImuaa3_-wpMK3_9O_wwAbRwV4uBCVzT_rY6rDQHR9-VkM0MbGK8drbdtjXwy3KtAzkux4N4g2nadYU0IIIEUNj_JChUFSHC7VRg7L7LpZMgAFGwHmaUyzQt31LyiVix9WFtcKfBgzehoRV6vstln-oBRd-vFjUW-7WLm7R_lFNHQZ0CKUvKCSpQxdIevczpYT0_lQiDU7Rfp1UBBnicndpq4YQwgRppdZX-QHG5IZxxHNWYdIBtn3eO3ooDXmI-rYryyVcFG6VY5xqssf8GgXqgNy84X_SgYfhGmNiPPQTl5wIBa49f1sxAI-7ri1xLr6FJhQMasOoxm_VuZYNkq6NrAXprtKa70cBRpm1wWBNcPdMul4GNMDQatQZRDENcNLe45iMX-HB1YYSvyc-y5rfSJkP4EgKXC-OqcrzxAlRfB3Bm4jOQL_PSsNBbh26CWAdAYSftM88fbnuqZX2w==\"},{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}],\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_052e7ec551f55289016aa8c8d63eac87d19d6b921611f123e7\",\"summary\":[],\"encrypted_content\":\"gAAAAABqqMjWZ2Eei8_Gf-5FeEFAYp-gSzFL4D4lQBKL_fyyTYXv5iJ-2jql1mOq0wZpqHL8O9MWxebQGW56Ahd-p21qrDD52CyUBqKKlF87eC1d-cTgjXQlFMsPxvwyQeuU6A2l8tanTtJ48sKtzZtHrDuBXZ35u-lONnovjFGMX3Q83xoqG_um_w5rT420TA_SyU4fGt7oiQvOPS1q4PNo97O824oRnI7n_BC1jPYCaJhl2I1rPJg4afuOpjG-u7JcXRD4JPwZdqMa5o2d0uDKHuUYwP25qiPKKDqTTqFka5cDJjZNPF3ZkVHR-cagjZGvMnizXXxgUpPJ9j83gqY4QJLKCkzcaBj9H7mAL-v9yl4I5kn_9_DhpMILs2SZkC8AvIYNgmel3sDV_BG4XZ2JXciZz86ukQ6DwXgQqS4HOB91g-sGHOWMU1ohsZlEvvJBjGkJ_rAdXVMqAbvi2zvE3_NI4sTAGUrIugGJePrQYTe8gqL8f9NsYac6pzHNQL1e_jQNUvp49bu7EsPzCP3KPYVvZCFohDdwe7sMd6wrztrCJwM4CLdAQK61A7sYzU0HyglLtPidmS5QkSmV6U_xgih7JbKnY0oAeCyYw4ADYqdNTi0axmBErh-lbh-XKNG_TnoMa-2IS3X041N8OsDfSdQp3QsAm53fF8seQuLFa27Iaq2etMj3yGeqWVjA-Mae3K34mt2YPGjQ-HbIOMVmYXBLzNr-s2fT35Sp6SDEsFyvzXb0Vij58s1wW5zkuKgaJiroGgkY86NImuaa3_-wpMK3_9O_wwAbRwV4uBCVzT_rY6rDQHR9-VkM0MbGK8drbdtjXwy3KtAzkux4N4g2nadYU0IIIEUNj_JChUFSHC7VRg7L7LpZMgAFGwHmaUyzQt31LyiVix9WFtcKfBgzehoRV6vstln-oBRd-vFjUW-7WLm7R_lFNHQZ0CKUvKCSpQxdIevczpYT0_lQiDU7Rfp1UBBnicndpq4YQwgRppdZX-QHG5IZxxHNWYdIBtn3eO3ooDXmI-rYryyVcFG6VY5xqssf8GgXqgNy84X_SgYfhGmNiPPQTl5wIBa49f1sxAI-7ri1xLr6FJhQMasOoxm_VuZYNkq6NrAXprtKa70cBRpm1wWBNcPdMul4GNMDQatQZRDENcNLe45iMX-HB1YYSvyc-y5rfSJkP4EgKXC-OqcrzxAlRfB3Bm4jOQL_PSsNBbh26CWAdAYSftM88fbnuqZX2w==\"},{\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}],\"status\":\"completed\"},{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"max_output_tokens\":40,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
Vendored
+1
-1
@@ -24,7 +24,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"instructions\":\"Show concise reasoning when the provider supports visible reasoning summaries.\",\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"max_output_tokens\":120,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
Vendored
+2
-2
@@ -25,7 +25,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":80,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
@@ -43,7 +43,7 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"id\":\"fc_09525c04931d1487016aa8c8d8e12887d193bd327a0f313bd7\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":80,\"stream\":true}"
|
||||
"body": "{\"model\":\"gpt-5.5\",\"input\":[{\"type\":\"message\",\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"id\":\"fc_09525c04931d1487016aa8c8d8e12887d193bd327a0f313bd7\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_p57PJbKe0bX44nj908fpHdRn\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":80,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type ImageModelOptions,
|
||||
type ImageOptions,
|
||||
type ImageRequestFor,
|
||||
type ImageRoute,
|
||||
} from "../src/index.js"
|
||||
import type { Service } from "../src/image-client.js"
|
||||
import { Anthropic, BlackForestLabs, Google, OpenAI, Stability, XAI, ZAI } from "../src/providers.js"
|
||||
@@ -21,8 +20,7 @@ type GoogleLikeOptions = {
|
||||
readonly thinkingLevel?: "LOW" | "HIGH"
|
||||
} & Record<string, unknown>
|
||||
|
||||
declare const route: ImageRoute<GoogleLikeOptions>
|
||||
const google = ImageModel.make<GoogleLikeOptions>({ id: "gemini-image", provider: "google", route })
|
||||
declare const google: ImageModel<GoogleLikeOptions>
|
||||
// @ts-expect-error Extracted model options retain known provider fields.
|
||||
const invalidGoogleOptions: ImageModelOptions<typeof google> = { imageSize: "8K" }
|
||||
void invalidGoogleOptions
|
||||
@@ -152,6 +150,8 @@ Image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { quality:
|
||||
Image.generate({ model: zai, prompt: "A lighthouse", providerOptions: { userID: 1 } })
|
||||
|
||||
declare const generic: ImageModel<ImageOptions>
|
||||
const widenImage = <Options extends ImageOptions>(model: ImageModel<Options>): ImageModel => model
|
||||
void widenImage
|
||||
Image.generate({ model: generic, prompt: "A lighthouse", providerOptions: { arbitrary: true } })
|
||||
const explicitAsset: Media.Asset = Media.url("https://example.com/image.png")
|
||||
void explicitAsset
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type LanguageModelProviderOptions,
|
||||
type ProviderOptions,
|
||||
} from "../src/index.js"
|
||||
import { ai } from "../src/promise.js"
|
||||
import { OpenAIChat } from "../src/protocols.js"
|
||||
|
||||
interface ExampleOptions {
|
||||
@@ -31,6 +32,10 @@ const generated = LLM.generate(LLM.request({ model, prompt: "Hello" }))
|
||||
type GenerateRequirements = Assert<Equal<Requirements<typeof generated>, LLMClientService>>
|
||||
const streamed = LLM.stream(LLM.request({ model, prompt: "Hello" }))
|
||||
type StreamClientRequirements = Assert<Equal<StreamRequirements<typeof streamed>, LLMClientService>>
|
||||
const generatedFromInput = LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } })
|
||||
type InputGenerateRequirements = Assert<Equal<Requirements<typeof generatedFromInput>, LLMClientService>>
|
||||
const streamedFromInput = LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } })
|
||||
type InputStreamRequirements = Assert<Equal<StreamRequirements<typeof streamedFromInput>, LLMClientService>>
|
||||
|
||||
LLM.request({
|
||||
model,
|
||||
@@ -39,6 +44,11 @@ LLM.request({
|
||||
providerOptions: { mode: "slow" },
|
||||
})
|
||||
|
||||
// @ts-expect-error Direct input keeps the selected model's provider option types.
|
||||
LLM.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
|
||||
// @ts-expect-error Stream input keeps the selected model's provider option types.
|
||||
LLM.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
|
||||
|
||||
const generatedObject = LLM.generateObject({
|
||||
model,
|
||||
prompt: "Hello",
|
||||
@@ -69,5 +79,16 @@ const options: LanguageModelProviderOptions<typeof model> = { mode: "fast" }
|
||||
void (options satisfies LanguageModelProviderOptions<typeof model>)
|
||||
void (true satisfies GenerateRequirements)
|
||||
void (true satisfies StreamClientRequirements)
|
||||
void (true satisfies InputGenerateRequirements)
|
||||
void (true satisfies InputStreamRequirements)
|
||||
void (true satisfies GenerateObjectRequirements)
|
||||
void (true satisfies GenerateDynamicObjectRequirements)
|
||||
|
||||
void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "fast" } })
|
||||
void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "thorough" } })
|
||||
void ai.llm.generate(ai.llm.request({ model, prompt: "Hello" }))
|
||||
void ai.llm.stream(ai.llm.request({ model, prompt: "Hello" }))
|
||||
// @ts-expect-error Promise direct input keeps the selected model's provider option types.
|
||||
void ai.llm.generate({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
|
||||
// @ts-expect-error Promise stream input keeps the selected model's provider option types.
|
||||
void ai.llm.stream({ model, prompt: "Hello", providerOptions: { mode: "slow" } })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { CacheHint, LLM, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { CacheHint, LLM, LLMEvent, LLMResponse, ToolEntry, ToolNamespace } from "../src/index.js"
|
||||
import { OpenAI } from "../src/providers.js"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
|
||||
import {
|
||||
@@ -13,6 +14,8 @@ import {
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
} from "../src/schema/index.js"
|
||||
import { fixedResponse } from "./lib/http.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
|
||||
const chatRoute = OpenAIChat.route
|
||||
const responsesRoute = OpenAIResponses.route
|
||||
@@ -240,6 +243,26 @@ describe("llm constructors", () => {
|
||||
expect(request.messages.map((message) => message.role)).toEqual(["user", "system"])
|
||||
})
|
||||
|
||||
test("generates and streams from input or a prebuilt request", async () => {
|
||||
const model = OpenAI.configure({ apiKey: "test", baseURL: "https://openai.test/v1" }).chat("gpt-4o-mini")
|
||||
const layer = fixedResponse(
|
||||
sseEvents({ choices: [{ delta: { content: "Hello" } }] }, { choices: [{ delta: {}, finish_reason: "stop" }] }),
|
||||
)
|
||||
const input = { model, prompt: "Say hello." }
|
||||
const request = LLM.request(input)
|
||||
const generated = await Effect.runPromise(LLM.generate(input).pipe(Effect.provide(layer)))
|
||||
const generatedFromRequest = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(layer)))
|
||||
expect(generated.text).toBe("Hello")
|
||||
expect(generatedFromRequest.text).toBe(generated.text)
|
||||
|
||||
const streamed = await Effect.runPromise(LLM.stream(input).pipe(Stream.runCollect, Effect.provide(layer)))
|
||||
const streamedFromRequest = await Effect.runPromise(
|
||||
LLM.stream(request).pipe(Stream.runCollect, Effect.provide(layer)),
|
||||
)
|
||||
expect(Array.from(streamed).some(LLMEvent.is.textDelta)).toBe(true)
|
||||
expect(streamedFromRequest).toEqual(streamed)
|
||||
})
|
||||
|
||||
test("extracts output text from response events", () => {
|
||||
expect(
|
||||
LLMResponse.text({
|
||||
|
||||
@@ -116,19 +116,32 @@ describe("AI promise client", () => {
|
||||
const seen: Array<string> = []
|
||||
const ai = AI.make({ layer: executor(seen) })
|
||||
|
||||
const text = await ai.llm.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })
|
||||
const request = ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })
|
||||
const text = await ai.llm.generate(request)
|
||||
expect(text.text).toBe("Hello world")
|
||||
expect((await ai.llm.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })).text).toBe(
|
||||
"Hello world",
|
||||
)
|
||||
|
||||
const image = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
|
||||
expect(image.image).toBeInstanceOf(Media.Asset)
|
||||
expect(image.image.mediaType).toBe("image/png")
|
||||
expect(await ai.run(image.image.bytes())).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
const requested = await ai.image.generate(
|
||||
ai.image.request({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" }),
|
||||
)
|
||||
expect(requested.image.mediaType).toBe("image/png")
|
||||
|
||||
const deltas: Array<string> = []
|
||||
for await (const event of ai.llm.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) {
|
||||
for await (const event of ai.llm.stream(request)) {
|
||||
if (LLMEvent.is.textDelta(event)) deltas.push(event.text)
|
||||
}
|
||||
expect(deltas).toEqual(["Hello", " world"])
|
||||
const directDeltas: Array<string> = []
|
||||
for await (const event of ai.llm.stream({ model: openai.chat("gpt-4o-mini"), prompt: "Say hello." })) {
|
||||
if (LLMEvent.is.textDelta(event)) directDeltas.push(event.text)
|
||||
}
|
||||
expect(directDeltas).toEqual(deltas)
|
||||
|
||||
const imageEvents: Array<string> = []
|
||||
for await (const event of ai.image.stream({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })) {
|
||||
@@ -137,8 +150,11 @@ describe("AI promise client", () => {
|
||||
expect(imageEvents).toEqual(["image-partial", "image", "finish"])
|
||||
|
||||
expect(seen).toEqual([
|
||||
"https://openai.test/v1/chat/completions",
|
||||
"https://openai.test/v1/chat/completions",
|
||||
"https://openai.test/v1/images/generations",
|
||||
"https://openai.test/v1/images/generations",
|
||||
"https://openai.test/v1/chat/completions",
|
||||
"https://openai.test/v1/chat/completions",
|
||||
"https://openai.test/v1/images/generations",
|
||||
])
|
||||
@@ -260,22 +276,27 @@ describe("AI promise client", () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
|
||||
const failure = await ai.llm
|
||||
.generate({ model: openai.responses("gpt-5"), prompt: "Hello" })
|
||||
.generate(ai.llm.request({ model: openai.responses("gpt-5"), prompt: "Hello" }))
|
||||
.then(() => undefined)
|
||||
.catch((error: unknown) => error)
|
||||
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.
|
||||
const invalidLLM = await ai.llm
|
||||
// @ts-expect-error Invalid input must reject with AIError instead of throwing synchronously.
|
||||
.generate({ model: openai.responses("gpt-5"), messages: [{ role: "bogus" }] })
|
||||
.catch((error: unknown) => error)
|
||||
expect(invalidLLM instanceof AIError && invalidLLM.reason._tag).toBe("InvalidRequest")
|
||||
|
||||
const invalid = await ai.image
|
||||
.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse", n: 1.5 })
|
||||
.catch((error: unknown) => error)
|
||||
expect(invalid instanceof AIError && invalid.reason._tag).toBe("InvalidRequest")
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const aborted = await ai.llm
|
||||
.generate({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }, { signal: controller.signal })
|
||||
.generate(ai.llm.request({ model: openai.chat("gpt-4o-mini"), prompt: "Hello" }), { signal: controller.signal })
|
||||
.then(() => "completed")
|
||||
.catch(() => "aborted")
|
||||
expect(aborted).toBe("aborted")
|
||||
|
||||
@@ -216,6 +216,33 @@ it.effect("Alibaba keeps native reasoning controls and future efforts on their s
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Alibaba fits explicit thinking budgets to half the output limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" })
|
||||
const chat = (maxTokens?: number) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: provider.chat("qwen3.7-plus"),
|
||||
prompt: "hi",
|
||||
...(maxTokens === undefined ? {} : { generation: { maxTokens } }),
|
||||
providerOptions: { enableThinking: true, thinkingBudget: 131_071 },
|
||||
}),
|
||||
).pipe(Effect.map((prepared) => prepared.body.thinking_budget))
|
||||
const messages = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: provider.messages("qwen3.7-plus"),
|
||||
prompt: "hi",
|
||||
generation: { maxTokens: 32_000 },
|
||||
providerOptions: { thinking: { type: "enabled", budgetTokens: 131_071 } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(yield* chat(32_000)).toBe(16_000)
|
||||
expect(yield* chat()).toBe(131_071)
|
||||
expect(messages.body.thinking).toEqual({ type: "enabled", budget_tokens: 16_000 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Alibaba validates malformed options before execution", () =>
|
||||
Effect.gen(function* () {
|
||||
const provider = Alibaba.configure({ region: "ap-southeast-1", apiKey: "fixture" })
|
||||
|
||||
@@ -148,11 +148,13 @@ describe("Anthropic Messages route", () => {
|
||||
Effect.gen(function* () {
|
||||
const enabled = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
generation: { maxTokens: 4_096 },
|
||||
providerOptions: { thinking: { type: "enabled", budgetTokens: 1_024 } },
|
||||
}),
|
||||
)
|
||||
const legacy = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
generation: { maxTokens: 4_096 },
|
||||
providerOptions: { thinking: { type: "enabled", budget_tokens: 2_048 } },
|
||||
}),
|
||||
)
|
||||
@@ -168,6 +170,22 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fits the thinking budget to half the output limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const thinking = (maxTokens: number) =>
|
||||
compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
generation: { maxTokens },
|
||||
providerOptions: { thinking: { type: "enabled", budgetTokens: 31_999 } },
|
||||
}),
|
||||
).pipe(Effect.map((prepared) => prepared.body.thinking))
|
||||
|
||||
expect(yield* thinking(64_000)).toEqual({ type: "enabled", budget_tokens: 31_999 })
|
||||
expect(yield* thinking(20_000)).toEqual({ type: "enabled", budget_tokens: 10_000 })
|
||||
expect(yield* thinking(1_500)).toEqual({ type: "enabled", budget_tokens: 1_024 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects enabled thinking without a budget", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
|
||||
@@ -244,6 +244,29 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fits a Claude thinking budget below maxTokens", () =>
|
||||
Effect.gen(function* () {
|
||||
const fields = (maxTokens: number, budgetTokens: number, topK?: number) =>
|
||||
compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
model: AmazonBedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0", {
|
||||
baseURL: "https://bedrock-runtime.test",
|
||||
apiKey: "test-bearer",
|
||||
thinking: { type: "enabled", budgetTokens },
|
||||
}),
|
||||
generation: GenerationOptions.make({ maxTokens, topK }),
|
||||
}),
|
||||
).pipe(Effect.map((prepared) => prepared.body.additionalModelRequestFields))
|
||||
|
||||
expect(yield* fields(64_000, 31_999)).toEqual({ thinking: { type: "enabled", budget_tokens: 31_999 } })
|
||||
expect(yield* fields(20_000, 31_999, 40)).toEqual({
|
||||
top_k: 40,
|
||||
thinking: { type: "enabled", budget_tokens: 10_000 },
|
||||
})
|
||||
expect(yield* fields(1_500, 31_999)).toEqual({ thinking: { type: "enabled", budget_tokens: 1_024 } })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits additionalModelRequestFields when topK is unset", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(baseRequest)
|
||||
|
||||
@@ -22,21 +22,22 @@ testEffect(
|
||||
expect(body).toMatchObject({
|
||||
model: "fixture",
|
||||
stream: true,
|
||||
store: false,
|
||||
store: true,
|
||||
instructions: "Keep the context",
|
||||
parallel_tool_calls: true,
|
||||
parallel_tool_calls: false,
|
||||
prompt_cache_key: "session-key",
|
||||
service_tier: "priority",
|
||||
reasoning: { effort: "high", summary: "auto" },
|
||||
context_management: [{ type: "compaction" }],
|
||||
max_tool_calls: 1,
|
||||
tool_choice: "required",
|
||||
text: { verbosity: "high", format: { type: "json_object" } },
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }, { type: "compaction_trigger" }],
|
||||
})
|
||||
expect(body.tools).toHaveLength(1)
|
||||
expect(body.tools[0].name).toBe("lookup")
|
||||
expect(body.tool_choice).toBeUndefined()
|
||||
expect(body.context_management).toBeUndefined()
|
||||
expect(body.text).toBeUndefined()
|
||||
expect(body.max_output_tokens).toBeUndefined()
|
||||
expect(body.previous_response_id).toBeUndefined()
|
||||
return respond(
|
||||
@@ -57,7 +58,7 @@ testEffect(
|
||||
)
|
||||
}),
|
||||
),
|
||||
).effect("trigger uses normal request preparation, configured deployment, and supplied subscription headers", () =>
|
||||
).effect("trigger keeps request controls, configured deployment, and supplied subscription headers", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const input = LLM.request({
|
||||
@@ -67,12 +68,14 @@ testEffect(
|
||||
promptCacheKey: "session-key",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
toolChoice: { type: "tool", name: "lookup" },
|
||||
generation: { maxTokens: 1 },
|
||||
providerOptions: {
|
||||
store: true,
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
contextManagement: [{ type: "compaction" }],
|
||||
parallelToolCalls: false,
|
||||
maxToolCalls: 1,
|
||||
textVerbosity: "low",
|
||||
},
|
||||
http: {
|
||||
headers: { "chatgpt-account-id": "fixture-account", "x-codex-beta-features": "remote_compaction_v2" },
|
||||
@@ -82,8 +85,7 @@ testEffect(
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "session", ttl: "1h" },
|
||||
store: true,
|
||||
stream: false,
|
||||
text: { format: { type: "json_object" } },
|
||||
text: { verbosity: "high", format: { type: "json_object" } },
|
||||
tool_choice: "required",
|
||||
},
|
||||
},
|
||||
@@ -114,6 +116,75 @@ testEffect(
|
||||
}),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(JSON.parse(text).text).toEqual({ verbosity: "low", format: { type: "json_object" } })
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect("keeps explicit verbosity on a trigger checkpoint for prompt cache reuse", () =>
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"),
|
||||
prompt: "Hello.",
|
||||
providerOptions: { textVerbosity: "low" },
|
||||
http: { body: { text: { format: { type: "json_object" } } } },
|
||||
}),
|
||||
trigger,
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(body.text).toEqual({ verbosity: "high", format: { type: "json_object" } })
|
||||
expect(body.max_output_tokens).toBe(20_000)
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [checkpoint] } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect("keeps the effective body-overlay verbosity and text formatting", () =>
|
||||
LLMClient.compact(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"),
|
||||
prompt: "Hello.",
|
||||
generation: { maxTokens: 20_000 },
|
||||
providerOptions: { textVerbosity: "low" },
|
||||
http: { body: { text: { verbosity: "high", format: { type: "json_object" } } } },
|
||||
}),
|
||||
trigger,
|
||||
),
|
||||
)
|
||||
|
||||
testEffect(
|
||||
dynamicResponse(({ text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(JSON.parse(text).max_output_tokens).toBe(128)
|
||||
return respond(JSON.stringify({ error: { message: "max_output_tokens must be at least 20000" } }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
).effect("passes configured output limits through and leaves rejection to the provider", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.compact(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.5"),
|
||||
prompt: "Hello.",
|
||||
generation: { maxTokens: 128 },
|
||||
}),
|
||||
trigger,
|
||||
).pipe(Effect.flip)
|
||||
expect(error.message).toContain("at least 20000")
|
||||
}),
|
||||
)
|
||||
|
||||
const idless = { type: "compaction", encrypted_content: "opaque" }
|
||||
testEffect(
|
||||
fixedResponse(
|
||||
@@ -184,7 +255,7 @@ testEffect(fixedResponse(sseEvents({ type: "response.output_item.done", item: ch
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
}),
|
||||
)
|
||||
for (const body of [{ input: [] }, { previous_response_id: "stale" }]) {
|
||||
for (const body of [{ input: [] }, { previous_response_id: "stale" }, { stream: false }]) {
|
||||
testEffect(dynamicResponse(() => Effect.die("Must reject before sending"))).effect(
|
||||
`rejects caller-supplied ${Object.keys(body)[0]} before sending trigger`,
|
||||
() =>
|
||||
|
||||
@@ -110,11 +110,16 @@ for (const model of [
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
expect(new URL(request.url).pathname).toEndWith("/responses/compact")
|
||||
expect(JSON.parse(text)).toEqual({ model: "fixture", input: [item], instructions: "Keep the context" })
|
||||
expect(JSON.parse(text)).toEqual({
|
||||
model: "fixture",
|
||||
input: [item],
|
||||
instructions: "Keep the context",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
})
|
||||
return respond(JSON.stringify({ object: "response.compaction", output: [checkpoint] }))
|
||||
}),
|
||||
),
|
||||
).effect(`${model.provider} compacts provider-specific history without lowering generation settings`, () =>
|
||||
).effect(`${model.provider} validates tools but ignores unrelated unsupported generation settings`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
@@ -151,6 +156,11 @@ for (const model of [
|
||||
] as const) {
|
||||
const error = yield* LLMClient.generate(candidate).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe(tag)
|
||||
if (candidate.tools.length > 0) {
|
||||
const compactError = yield* LLMClient.compact(candidate).pipe(Effect.flip)
|
||||
expect(compactError.reason._tag).toBe("InvalidRequest")
|
||||
continue
|
||||
}
|
||||
const response = yield* LLMClient.compact(candidate)
|
||||
expect(response.replacement[0]?.content[0]?.type).toBe("compaction")
|
||||
}
|
||||
@@ -255,6 +265,13 @@ for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key:
|
||||
model: "fixture",
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }],
|
||||
service_tier: overlay?.service_tier ?? "flex",
|
||||
reasoning: { effort: "low" },
|
||||
text: { verbosity: "low", format: { type: "json_object" } },
|
||||
include: ["reasoning.encrypted_content"],
|
||||
parallel_tool_calls: false,
|
||||
tools: [
|
||||
{ type: "function", name: "lookup", description: "Lookup", parameters: { type: "object" }, strict: false },
|
||||
],
|
||||
prompt_cache_key: overlay?.prompt_cache_key ?? "affinity",
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "explicit", ttl: "30m" },
|
||||
@@ -268,12 +285,20 @@ for (const overlay of [undefined, { service_tier: "priority", prompt_cache_key:
|
||||
model: OpenAI.configure({ apiKey: "test" }).responses("fixture"),
|
||||
prompt: "hello",
|
||||
promptCacheKey: "affinity",
|
||||
providerOptions: { serviceTier: "flex" },
|
||||
providerOptions: {
|
||||
serviceTier: "flex",
|
||||
reasoningEffort: "low",
|
||||
textVerbosity: "low",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
parallelToolCalls: false,
|
||||
},
|
||||
generation: { maxTokens: 100 },
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: {} }],
|
||||
http: {
|
||||
body: {
|
||||
stream: true,
|
||||
store: false,
|
||||
text: { format: { type: "json_object" } },
|
||||
prompt_cache_retention: "24h",
|
||||
prompt_cache_options: { mode: "explicit", ttl: "30m" },
|
||||
...overlay,
|
||||
@@ -396,6 +421,8 @@ for (const model of [
|
||||
model: model.id,
|
||||
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "original" }] }],
|
||||
instructions: "system",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
...(model.id === "gpt-5.3-codex" ? { reasoning: { effort: "medium", summary: "auto" } } : {}),
|
||||
})
|
||||
return respond(
|
||||
JSON.stringify({
|
||||
@@ -407,7 +434,10 @@ for (const model of [
|
||||
)
|
||||
}
|
||||
expect(new URL(request.url).pathname.endsWith("/responses")).toBe(true)
|
||||
expect(body.input).toEqual([...output, { type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] }])
|
||||
expect(body.input).toEqual([
|
||||
...output,
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "continue" }] },
|
||||
])
|
||||
return respond(sseEvents({ type: "response.completed", response: { id: "resp_1", output: [] } }), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
|
||||
@@ -90,6 +90,23 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fits the thinking budget to half the output limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const thinkingBudget = (budget: number, maxTokens = 32_000) =>
|
||||
compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
generation: { maxTokens },
|
||||
providerOptions: { thinkingConfig: { thinkingBudget: budget } },
|
||||
}),
|
||||
).pipe(Effect.map((prepared) => prepared.body.generationConfig?.thinkingConfig?.thinkingBudget))
|
||||
|
||||
expect(yield* thinkingBudget(32_768)).toBe(16_000)
|
||||
expect(yield* thinkingBudget(8_000)).toBe(8_000)
|
||||
expect(yield* thinkingBudget(-1)).toBe(-1)
|
||||
expect(yield* thinkingBudget(8_192, 1_000)).toBe(512)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forwards standard Gemini generation options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -1945,7 +1945,7 @@ describe("OpenAI Responses route", () => {
|
||||
expect(prepared.body.prompt_cache_key).toBe("session_123")
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "high", summary: "auto" })
|
||||
expect(prepared.body.text).toEqual({ verbosity: "low" })
|
||||
expect(prepared.body.text).toBeUndefined()
|
||||
expect(prepared.body.metadata).toEqual({ environment: "test", tenant: "acme" })
|
||||
expect(prepared.body.safety_identifier).toBe("user_123")
|
||||
expect(prepared.body.stream_options).toEqual({ include_obfuscation: false })
|
||||
|
||||
@@ -152,6 +152,27 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fits the reasoning budget to half the output limit", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasoning = (maxTokens: number | undefined, value: Record<string, unknown>) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("qwen/qwen3.8-flash"),
|
||||
cache: "none",
|
||||
prompt: "Hello",
|
||||
...(maxTokens === undefined ? {} : { generation: { maxTokens } }),
|
||||
providerOptions: { reasoning: value },
|
||||
}),
|
||||
).pipe(Effect.map((prepared) => prepared.body.reasoning))
|
||||
|
||||
expect(yield* reasoning(32_000, { max_tokens: 131_071 })).toEqual({ max_tokens: 16_000 })
|
||||
expect(yield* reasoning(131_072, { max_tokens: 65_536 })).toEqual({ max_tokens: 65_536 })
|
||||
expect(yield* reasoning(1_500, { max_tokens: 65_536 })).toEqual({ max_tokens: 1_024 })
|
||||
expect(yield* reasoning(undefined, { max_tokens: 131_071 })).toEqual({ max_tokens: 131_071 })
|
||||
expect(yield* reasoning(32_000, { effort: "high" })).toEqual({ effort: "high" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies OpenRouter payload options from the model helper", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -28,10 +28,49 @@ const cartesia = Cartesia.configure({ apiKey: "test", baseURL: "https://cartesia
|
||||
const google = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).speech(
|
||||
"gemini-2.5-flash-preview-tts",
|
||||
)
|
||||
const google38 = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).speech(
|
||||
"gemini-3.8-flash-tts",
|
||||
)
|
||||
const google38Lite = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).speech(
|
||||
"gemini-3.8-flash-lite-tts",
|
||||
)
|
||||
const deepgram = Deepgram.configure({ apiKey: "test", baseURL: "https://deepgram.test" }).speech("aura-2-thalia-en")
|
||||
const voice = "JBFqnCBsd6RMkjVDRZzb"
|
||||
|
||||
describe("Speech", () => {
|
||||
it.effect("preserves Google's WAV output instead of describing it as raw PCM", () =>
|
||||
Effect.gen(function* () {
|
||||
const bytes = new TextEncoder().encode("RIFF....WAVEfmt ")
|
||||
const response = yield* Speech.generate({ model: google38, text: "Hi" }).pipe(
|
||||
Effect.provide(
|
||||
respond(
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{ content: { parts: [{ inlineData: { mimeType: "audio/wav", data: Encoding.encodeBase64(bytes) } }] } },
|
||||
],
|
||||
}),
|
||||
"application/json",
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.audio.mediaType).toBe("audio/wav")
|
||||
expect(response.audio.info?.format).toBe("wav")
|
||||
expect(response.audio.info?.encoding).toBeUndefined()
|
||||
expect(yield* response.audio.bytes()).toEqual(bytes)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects raw PCM for Gemini 3.8 unary requests before sending", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
[google38, google38Lite].map((model) =>
|
||||
Speech.generate({ model, text: "Hi", format: "pcm" }).pipe(Effect.flip),
|
||||
),
|
||||
).pipe(Effect.provide(layer(() => Effect.die("An unsupported request reached the network"))))
|
||||
expect(errors.map((error) => error.reason._tag)).toEqual(["UnsupportedOperation", "UnsupportedOperation"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects what a provider cannot produce before sending anything", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Stream } from "effect"
|
||||
import { Speech, type SpeechEvent } from "../src/index.js"
|
||||
import { Speech, SpeechModel, type SpeechEvent, type SpeechOptions } from "../src/index.js"
|
||||
import { ElevenLabs, OpenAI, Runway } from "../src/providers.js"
|
||||
|
||||
type StreamItem<T> = T extends Stream.Stream<infer A, infer _E, infer _R> ? A : never
|
||||
@@ -7,6 +7,8 @@ type Equal<A, B> = [A, B] extends [B, A] ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
const elevenlabs = ElevenLabs.configure({ apiKey: "test" }).speech("eleven_flash_v2_5")
|
||||
const widenSpeech = <Options extends SpeechOptions>(model: SpeechModel<Options>): SpeechModel => model
|
||||
void widenSpeech
|
||||
|
||||
Speech.generate({
|
||||
model: elevenlabs,
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Stream } from "effect"
|
||||
import { Media, Transcription, type TranscriptionEvent } from "../src/index.js"
|
||||
import {
|
||||
Media,
|
||||
Transcription,
|
||||
TranscriptionModel,
|
||||
type TranscriptionEvent,
|
||||
type TranscriptionOptions,
|
||||
} from "../src/index.js"
|
||||
import { AssemblyAI, Deepgram, OpenAI } from "../src/providers.js"
|
||||
|
||||
type StreamItem<T> = T extends Stream.Stream<infer A, infer _E, infer _R> ? A : never
|
||||
@@ -8,6 +14,10 @@ type Assert<T extends true> = T
|
||||
|
||||
const audio = Media.url("https://example.com/call.mp3")
|
||||
const deepgram = Deepgram.configure({ apiKey: "test" }).transcription("nova-3")
|
||||
const widenTranscription = <Options extends TranscriptionOptions>(
|
||||
model: TranscriptionModel<Options>,
|
||||
): TranscriptionModel => model
|
||||
void widenTranscription
|
||||
|
||||
Transcription.generate({
|
||||
model: deepgram,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type VideoModelOptions,
|
||||
type VideoOptions,
|
||||
type VideoRequestFor,
|
||||
type VideoRoute,
|
||||
} from "../src/index.js"
|
||||
import type { Service } from "../src/video-client.js"
|
||||
import { Anthropic, Fal, Google, OpenAI, Runway, XAI } from "../src/providers.js"
|
||||
@@ -23,8 +22,7 @@ type VeoLikeOptions = {
|
||||
readonly personGeneration?: "allow_all" | "allow_adult"
|
||||
} & Record<string, unknown>
|
||||
|
||||
declare const route: VideoRoute<VeoLikeOptions>
|
||||
const veo = VideoModel.make<VeoLikeOptions>({ id: "veo", provider: "google", route })
|
||||
declare const veo: VideoModel<VeoLikeOptions>
|
||||
// @ts-expect-error Extracted model options retain known provider fields.
|
||||
const invalidVeoOptions: VideoModelOptions<typeof veo> = { personGeneration: "everyone" }
|
||||
void invalidVeoOptions
|
||||
@@ -99,6 +97,8 @@ Video.generate({ model: google, prompt: "A kitten", durationSeconds: "8s" })
|
||||
Video.generate({ model: google, prompt: "A kitten", options: { personGeneration: "allow_all" } })
|
||||
|
||||
declare const generic: VideoModel<VideoOptions>
|
||||
const widenVideo = <Options extends VideoOptions>(model: VideoModel<Options>): VideoModel => model
|
||||
void widenVideo
|
||||
Video.generate({ model: generic, prompt: "A kitten", providerOptions: { arbitrary: true } })
|
||||
|
||||
const request = Video.request({ model: veo, prompt: "A kitten", providerOptions: { personGeneration: "allow_all" } })
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { Portal, render } from "solid-js/web"
|
||||
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
|
||||
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
|
||||
import type { createSessionBrowser } from "../src/session/browser/model"
|
||||
@@ -27,7 +27,12 @@ export function mountBrowserPane() {
|
||||
loadErrors: {} as Record<string, string | undefined>,
|
||||
error: undefined as string | undefined,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
covered: false,
|
||||
captures: 0,
|
||||
holdCapture: false,
|
||||
})
|
||||
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
|
||||
const held: (() => void)[] = []
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
|
||||
title: name,
|
||||
@@ -44,6 +49,17 @@ export function mountBrowserPane() {
|
||||
{
|
||||
setLayout: (layout) => setStore("layouts", tab.title, layout),
|
||||
command: async () => undefined,
|
||||
capture: async () => {
|
||||
setStore("captures", (count) => count + 1)
|
||||
if (store.holdCapture) await new Promise<void>((resolve) => held.push(resolve))
|
||||
const canvas = new OffscreenCanvas(4, 4)
|
||||
const paint = canvas.getContext("2d")
|
||||
if (paint) {
|
||||
paint.fillStyle = "#3b82f6"
|
||||
paint.fillRect(0, 0, 4, 4)
|
||||
}
|
||||
return canvas.convertToBlob()
|
||||
},
|
||||
close: () => undefined,
|
||||
},
|
||||
]),
|
||||
@@ -118,12 +134,34 @@ export function mountBrowserPane() {
|
||||
Complete navigation
|
||||
</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
|
||||
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
|
||||
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
|
||||
</nav>
|
||||
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<p>Captures: {store.captures}</p>
|
||||
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<Show when={store.mounted}>
|
||||
<SessionBrowserPane browser={browser} visible={store.visible} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={store.covered}>
|
||||
{/* Floating content portals into <body> like a menu or hover card over the page. */}
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
data-popper-positioner
|
||||
data-testid="fixture-popover"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "320px",
|
||||
height: "480px",
|
||||
"z-index": "1001",
|
||||
"pointer-events": "none",
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
</Show>
|
||||
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
|
||||
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
|
||||
<For each={tabs}>
|
||||
|
||||
@@ -58,6 +58,27 @@ story("hides the native view immediately while the pane stays mounted", async ({
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("keeps a still of the page under floating content that covers it", async ({ page }, testInfo) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const still = root.locator("#browser-panel img")
|
||||
await root.getByRole("button", { name: "Hold capture", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
|
||||
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
|
||||
// The native page stays up until its still is ready, so the pane never shows blank.
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(still).toHaveCount(0)
|
||||
|
||||
await root.getByRole("button", { name: "Release capture", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(still).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("covered.png") })
|
||||
|
||||
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(still).toHaveCount(0)
|
||||
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
@@ -14,7 +14,12 @@ type InboxRow = {
|
||||
sessionID: string
|
||||
time: { created: number }
|
||||
type: "user"
|
||||
payload: { text: string; metadata?: Record<string, unknown> }
|
||||
payload: {
|
||||
text: string
|
||||
metadata?: Record<string, unknown>
|
||||
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
|
||||
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
|
||||
}
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
|
||||
@@ -29,7 +34,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
}))
|
||||
const events: OpenCodeEvent[] = []
|
||||
const prompts: Record<string, unknown>[] = []
|
||||
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
|
||||
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
|
||||
const log: string[] = []
|
||||
let sequence = 0
|
||||
const emit = <Type extends OpenCodeEvent["type"]>(
|
||||
@@ -234,6 +239,108 @@ test("editing restores the existing draft and replaces only the original queue p
|
||||
expect(mock.log[0]).toBe("prompt:queue")
|
||||
})
|
||||
|
||||
test("Undo cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
|
||||
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
|
||||
const view = await openSession(page, mock)
|
||||
await expect(view.rows).toHaveCount(3)
|
||||
|
||||
const row = view.rows.filter({ hasText: "second queued prompt" })
|
||||
const actions = row.locator('[data-slot="session-queue-actions"] button')
|
||||
await expect(actions).toHaveCount(3)
|
||||
expect(
|
||||
await actions.evaluateAll((buttons) =>
|
||||
buttons.map((button) => button.getAttribute("aria-label") ?? button.textContent?.trim()),
|
||||
),
|
||||
).toEqual(["Steer", "Undo", "Remove"])
|
||||
const undo = row.getByRole("button", { name: "Undo" })
|
||||
await expect(undo).toHaveText("")
|
||||
await expect(undo.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-down-to-line")
|
||||
await undo.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Undo")
|
||||
await undo.click()
|
||||
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
|
||||
"first queued prompt",
|
||||
"third queued prompt",
|
||||
])
|
||||
await expect(view.input).toHaveText("second queued prompt")
|
||||
await expect(view.input).toBeFocused()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_2", action: "cancel" }])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("Undo appends to an existing draft and restores inline attachments", async ({ page }) => {
|
||||
const mock = createQueueMock(["queued with image"])
|
||||
mock.rows[0].payload.files = [
|
||||
{
|
||||
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL/nwAAAABJRU5ErkJggg==",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "shot.png",
|
||||
},
|
||||
]
|
||||
const view = await openSession(page, mock)
|
||||
await view.input.fill("my draft")
|
||||
await view.rows.getByRole("button", { name: "Undo" }).click()
|
||||
await expect(view.rows).toHaveCount(0)
|
||||
await expect(view.input).toHaveText("my draft\n\nqueued with image")
|
||||
await expect(view.input).toBeFocused()
|
||||
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
|
||||
})
|
||||
|
||||
test("Undo stays usable with a long queue on a narrow screen", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
|
||||
const mock = createQueueMock([text, ...Array.from({ length: 6 }, (_, index) => `queued follow-up ${index + 1}`)])
|
||||
const view = await openSession(page, mock)
|
||||
await expect(view.rows).toHaveCount(7)
|
||||
const row = view.rows.filter({ hasText: text })
|
||||
await row.getByRole("button", { name: "Undo" }).hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Undo")
|
||||
await page.screenshot({ path: testInfo.outputPath("undo-narrow-queue.png") })
|
||||
await row.getByRole("button", { name: "Undo" }).click()
|
||||
await expect(view.rows).toHaveCount(6)
|
||||
await expect(view.input).toHaveText(text)
|
||||
await expect(view.input).toBeFocused()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
|
||||
})
|
||||
|
||||
test("Undo preserves mentioned file and agent references on resubmission", async ({ page }) => {
|
||||
const mock = createQueueMock(["inspect @main.ts with @build"])
|
||||
mock.rows[0].payload.files = [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/main.ts" },
|
||||
name: "main.ts",
|
||||
mention: { start: 8, end: 16, text: "@main.ts" },
|
||||
},
|
||||
]
|
||||
mock.rows[0].payload.agents = [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }]
|
||||
const view = await openSession(page, mock)
|
||||
await view.rows.getByRole("button", { name: "Undo" }).click()
|
||||
await expect(view.input).toHaveText("inspect @main.ts with @build")
|
||||
await view.input.press("Enter")
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.prompts[0].files).toMatchObject([
|
||||
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
|
||||
])
|
||||
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
|
||||
})
|
||||
|
||||
test("Undo does not discard hidden file context", async ({ page }) => {
|
||||
const mock = createQueueMock(["inspect this file"])
|
||||
mock.rows[0].payload.files = [
|
||||
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
|
||||
]
|
||||
const view = await openSession(page, mock)
|
||||
await view.rows.getByRole("button", { name: "Undo" }).click()
|
||||
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
|
||||
await expect(view.rows).toHaveCount(1)
|
||||
await expect(view.input).toHaveText("")
|
||||
expect(mock.changes).toEqual([])
|
||||
})
|
||||
|
||||
for (const delivery of ["steer", "queue"] as const) {
|
||||
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
|
||||
page,
|
||||
|
||||
@@ -47,6 +47,7 @@ export type ComposerDelivery = "steer" | "queue"
|
||||
// is loaded in the editor.
|
||||
export type ComposerQueue = {
|
||||
count: Accessor<number>
|
||||
undoing: Accessor<boolean>
|
||||
// Delivery a plain submit uses right now.
|
||||
delivery: Accessor<ComposerDelivery>
|
||||
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
|
||||
|
||||
@@ -168,6 +168,7 @@ function ComposerStory(props: {
|
||||
alternate: () => props.alternate,
|
||||
editing: () => undefined,
|
||||
confirmEdit() {},
|
||||
undoing: () => false,
|
||||
cancelEdit() {},
|
||||
editFirst: () => false,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function Composer(props: {
|
||||
class?: string
|
||||
model: ComposerModel
|
||||
borderUnderlay?: boolean
|
||||
readOnly?: boolean
|
||||
suggestionBoundary?: () => HTMLElement | undefined
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
@@ -27,6 +28,7 @@ export function Composer(props: {
|
||||
<ComposerEditor
|
||||
controller={props.model}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
readOnly={props.readOnly}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
|
||||
@@ -371,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onSubmit: (submitOptions) => {
|
||||
if (!available()) return
|
||||
const queue = options?.queue
|
||||
if (queue?.undoing()) return
|
||||
// Confirming an edit re-admits the queued prompt instead of sending
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
// place; the alternate action sends it as a steer.
|
||||
|
||||
@@ -86,6 +86,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
sessions: indexedSessions,
|
||||
projectDirectories,
|
||||
projects: home.project.list,
|
||||
resolveProject: (session) => home.server.focusedContext()?.projects.forSession(session),
|
||||
}),
|
||||
)
|
||||
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
|
||||
@@ -291,6 +292,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
sessions: () => [result],
|
||||
projectDirectories,
|
||||
projects: home.project.list,
|
||||
resolveProject: ctx.projects.forSession,
|
||||
})[0]
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
|
||||
@@ -18,6 +18,7 @@ export function buildHomeSessionRecords(input: {
|
||||
sessions: () => SessionInfo[]
|
||||
projectDirectories: () => string[] | undefined
|
||||
projects: () => LocalProject[]
|
||||
resolveProject?: (session: SessionInfo) => LocalProject | undefined
|
||||
}) {
|
||||
const selected = input.projectDirectories()
|
||||
const directories = selected ? new Set(selected.map(pathKey)) : undefined
|
||||
@@ -27,11 +28,12 @@ export function buildHomeSessionRecords(input: {
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort(compareSessionTime)
|
||||
.map((session) => {
|
||||
const project = homeProjectForSession(session, input.projects()) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
}
|
||||
const project = input.resolveProject?.(session) ??
|
||||
homeProjectForSession(session, input.projects()) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
}
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -874,6 +874,9 @@ export const dict = {
|
||||
"session.queue.send": "Send",
|
||||
"session.queue.steerTooltip": "Send without interrupting",
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.undo": "Undo",
|
||||
"session.queue.undoShell": "Leave shell mode before undoing a queued prompt",
|
||||
"session.queue.undoUnavailable": "Edit this prompt in the queue to preserve its file context",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.attachments.one": "{{count}} attachment",
|
||||
"session.queue.attachments.other": "{{count}} attachments",
|
||||
@@ -1439,6 +1442,15 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Connected providers",
|
||||
"settings.providers.connected.empty": "No connected providers",
|
||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||
"settings.providers.account.manage": "Manage {{provider}} accounts",
|
||||
"settings.providers.account.group": "Accounts",
|
||||
"settings.providers.account.add": "Add account",
|
||||
"settings.providers.account.remove": "Remove account…",
|
||||
"settings.providers.account.active": "Active",
|
||||
"settings.providers.account.switched.title": "{{provider}} account switched",
|
||||
"settings.providers.account.switched.description": "Now using {{account}}.",
|
||||
"settings.providers.account.removed.title": "{{account}} removed",
|
||||
"settings.providers.account.removed.description": "{{provider}} will no longer use this account.",
|
||||
"settings.providers.console.available.one": "{{count}} provider available",
|
||||
"settings.providers.console.available.other": "{{count}} providers available",
|
||||
"settings.providers.section.popular": "Popular providers",
|
||||
|
||||
@@ -25,6 +25,8 @@ export type BrowserPaneEvent =
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
/** Captures the shown page, or resolves null when nothing is on screen. */
|
||||
capture(tabID: Browser.TabID): Promise<Blob | null>
|
||||
close(): void
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { resolveProjectForSession, resolveSessionDetailsProject } from "@/shell/layout/helpers"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -180,6 +182,13 @@ function createServerController(
|
||||
}
|
||||
|
||||
const projectsList = createMemo(() => projects.list().map(enrich))
|
||||
const forSession = (session: SessionInfo) => {
|
||||
const project = resolveProjectForSession(session, projectsList(), sync.data.project)
|
||||
if (!project) return
|
||||
return "expanded" in project ? project : { ...project, expanded: false }
|
||||
}
|
||||
const detailsForSession = (session: SessionInfo) =>
|
||||
resolveSessionDetailsProject(session, projectsList(), sync.data.project)
|
||||
const recentlyClosedList = createMemo(() => {
|
||||
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
|
||||
return projects
|
||||
@@ -200,6 +209,8 @@ function createServerController(
|
||||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
forSession,
|
||||
detailsForSession,
|
||||
resolve: enrich,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
|
||||
@@ -43,6 +43,9 @@ function fixture() {
|
||||
async command(command) {
|
||||
call.commands.push(command)
|
||||
},
|
||||
async capture() {
|
||||
return null
|
||||
},
|
||||
close() {
|
||||
call.closed = true
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
|
||||
@@ -30,6 +31,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
|
||||
navigating: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
// A still of the page shown in the DOM while floating content covers the hidden native view.
|
||||
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
|
||||
})
|
||||
const empty = () => !address() && !state()?.loading && !store.navigating
|
||||
let surface: HTMLDivElement | undefined
|
||||
@@ -37,6 +40,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
let capturing: Browser.TabID | undefined
|
||||
let release: ReturnType<typeof setTimeout> | undefined
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
@@ -69,6 +74,45 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
|
||||
})
|
||||
const replaceSnapshot = (next?: { tabID: Browser.TabID; url: string }) => {
|
||||
if (store.snapshot?.url) URL.revokeObjectURL(store.snapshot.url)
|
||||
setStore("snapshot", next)
|
||||
}
|
||||
// Keep the page on screen as a still under the floating content. The native view
|
||||
// stays visible until the still has decoded, so the pane never flashes blank.
|
||||
const freeze = (tabID: Browser.TabID) => {
|
||||
clearTimeout(release)
|
||||
release = undefined
|
||||
if (store.snapshot?.tabID === tabID || capturing === tabID) return
|
||||
capturing = tabID
|
||||
void (registration()?.capture(tabID) ?? Promise.resolve(null))
|
||||
.catch(() => null)
|
||||
.then(async (blob) => {
|
||||
const url = blob ? URL.createObjectURL(blob) : ""
|
||||
if (url) {
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
await image.decode().catch(() => undefined)
|
||||
}
|
||||
if (capturing !== tabID) {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
capturing = undefined
|
||||
// A failed capture still hides the page; the pane shows its background as before.
|
||||
replaceSnapshot({ tabID, url })
|
||||
schedule()
|
||||
})
|
||||
}
|
||||
const thaw = () => {
|
||||
capturing = undefined
|
||||
if (!store.snapshot || release !== undefined) return
|
||||
// Keep the still under the native view until the view has painted again.
|
||||
release = setTimeout(() => {
|
||||
release = undefined
|
||||
replaceSnapshot()
|
||||
}, 150)
|
||||
}
|
||||
const measure = () => {
|
||||
if (!surface) return
|
||||
const tab = state()
|
||||
@@ -84,7 +128,11 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
// The desktop page hides blank and loading documents itself; only hide here
|
||||
// while the pane shows its own empty or failed state over the surface.
|
||||
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
|
||||
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
|
||||
const cover = covered(rect)
|
||||
if (shown && cover) freeze(tab.id)
|
||||
if (!cover) thaw()
|
||||
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
@@ -186,6 +234,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
clearTimeout(release)
|
||||
capturing = undefined
|
||||
replaceSnapshot()
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -296,7 +347,17 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<div ref={surface} class="relative min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<Show when={store.snapshot?.tabID === state()?.id && !empty() && !failed() && store.snapshot?.url}>
|
||||
{(url) => (
|
||||
<img
|
||||
src={url()}
|
||||
alt=""
|
||||
draggable={false}
|
||||
class="absolute inset-0 size-full pointer-events-none select-none"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={(empty() || failed()) && !props.browser.suspended()}>
|
||||
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
|
||||
<div
|
||||
|
||||
@@ -175,6 +175,18 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip placement="top" value={language.t("session.queue.undo")}>
|
||||
<IconButton
|
||||
data-action="session-queue-undo"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name="arrow-down-to-line" />}
|
||||
disabled={props.queue.busy()}
|
||||
aria-label={language.t("session.queue.undo")}
|
||||
onClick={() => props.queue.undo(props.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Tooltip placement="top" value={language.t("session.queue.remove")}>
|
||||
<IconButton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { queuedPromptAttachments, queuedPromptRows } from "./queue"
|
||||
import { queuedPromptAttachments, queuedPromptUndoDraft, queuedPromptRows } from "./queue"
|
||||
|
||||
const queued = [
|
||||
{
|
||||
@@ -104,3 +104,43 @@ describe("queuedPromptAttachments", () => {
|
||||
expect(queuedPromptAttachments(item)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("queuedPromptUndoDraft", () => {
|
||||
test("keeps full text, structured mentions, and inline images", () => {
|
||||
const item = {
|
||||
...queued[0],
|
||||
payload: {
|
||||
text: "inspect @main.ts with @build",
|
||||
files: [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri" as const, uri: "file:///repo/main.ts" },
|
||||
name: "main.ts",
|
||||
mention: { start: 8, end: 16, text: "@main.ts" },
|
||||
},
|
||||
{ data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" },
|
||||
],
|
||||
agents: [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }],
|
||||
},
|
||||
} satisfies SessionInboxInfo
|
||||
expect(queuedPromptUndoDraft(item)).toMatchObject([
|
||||
{ type: "text", content: "inspect " },
|
||||
{ type: "file", content: "@main.ts", url: "data:text/plain;base64,aGk=" },
|
||||
{ type: "text", content: " with " },
|
||||
{ type: "agent", content: "@build", name: "build" },
|
||||
{ type: "image", filename: "shot.png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not drop hidden file context", () => {
|
||||
const item = {
|
||||
...queued[0],
|
||||
payload: {
|
||||
text: "inspect this",
|
||||
files: [{ data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///repo/main.ts" } }],
|
||||
},
|
||||
} satisfies SessionInboxInfo
|
||||
expect(queuedPromptUndoDraft(item)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,10 +3,11 @@ import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
|
||||
import { appendPrompt, clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
|
||||
import { buildPromptRequest } from "@/composer/request"
|
||||
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { readPromptPresentation } from "@/composer/comment-note"
|
||||
@@ -42,6 +43,7 @@ export function createSessionQueue(input: {
|
||||
mutationFn: async (
|
||||
change:
|
||||
| { type: "reorder"; inboxIDs: string[] }
|
||||
| { type: "undo"; item: QueuedPrompt; prompt: Prompt }
|
||||
| {
|
||||
type: "edit"
|
||||
inboxIDs: string[]
|
||||
@@ -54,6 +56,16 @@ export function createSessionQueue(input: {
|
||||
},
|
||||
) => {
|
||||
if (change.type === "reorder") return rewrite(change.inboxIDs)
|
||||
if (change.type === "undo") {
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
|
||||
const draft = input.draft.current()
|
||||
const prompt = promptLength(draft)
|
||||
? appendPrompt(draft, change.prompt)
|
||||
: [...change.prompt, ...draft.filter(isAttachment)]
|
||||
input.draft.set(prompt, promptLength(prompt))
|
||||
input.restoreFocus(promptLength(prompt))
|
||||
return
|
||||
}
|
||||
const replacement = await editedPromptInput(
|
||||
input.sessionID,
|
||||
location().directory,
|
||||
@@ -139,6 +151,21 @@ export function createSessionQueue(input: {
|
||||
if (state.editing?.id === id) cancelEdit()
|
||||
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
|
||||
}
|
||||
const undo = (id: string) => {
|
||||
if (mutation.isPending || state.editing) return
|
||||
const item = queued().find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
if (input.draft.mode.current() !== "normal") {
|
||||
showToast({ title: language.t("session.queue.undoShell") })
|
||||
return
|
||||
}
|
||||
const prompt = queuedPromptUndoDraft(item)
|
||||
if (!prompt) {
|
||||
showToast({ title: language.t("session.queue.undoUnavailable") })
|
||||
return
|
||||
}
|
||||
mutation.mutate({ type: "undo", item, prompt })
|
||||
}
|
||||
const reorder = (inboxIDs: string[]) => {
|
||||
if (mutation.isPending) return Promise.resolve()
|
||||
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
|
||||
@@ -226,9 +253,11 @@ export function createSessionQueue(input: {
|
||||
editFirst,
|
||||
rows,
|
||||
busy: () => mutation.isPending,
|
||||
undoing: () => mutation.isPending && mutation.variables?.type === "undo",
|
||||
working: input.working,
|
||||
steer,
|
||||
remove,
|
||||
undo,
|
||||
edit,
|
||||
reorder,
|
||||
}
|
||||
@@ -239,7 +268,7 @@ export type SessionQueue = ReturnType<typeof createSessionQueue>
|
||||
// The slice of the queue the panel renders and drives.
|
||||
export type SessionQueueView = Pick<
|
||||
SessionQueue,
|
||||
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
|
||||
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "undo" | "edit" | "reorder"
|
||||
>
|
||||
|
||||
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
|
||||
@@ -249,7 +278,8 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
attachments:
|
||||
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -287,6 +317,88 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
|
||||
]
|
||||
}
|
||||
|
||||
// Use the full model-visible text so comment notes and path references remain
|
||||
// in the draft. Convert mentioned files, agents, and skills back into editor
|
||||
// parts; a detached draft cannot represent non-mentioned file context.
|
||||
export function queuedPromptUndoDraft(item: QueuedPrompt): Prompt | undefined {
|
||||
if (
|
||||
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
|
||||
item.payload.agents?.some((agent) => !agent.mention) ||
|
||||
item.payload.skills?.some((skill) => !skill.mention)
|
||||
)
|
||||
return
|
||||
const text = item.payload.text
|
||||
const references = [
|
||||
...(item.payload.files ?? []).flatMap((file) =>
|
||||
file.mention
|
||||
? [
|
||||
{
|
||||
type: "file" as const,
|
||||
content: file.mention.text,
|
||||
start: file.mention.start,
|
||||
end: file.mention.end,
|
||||
path: file.name ?? file.mention.text.replace(/^@/, ""),
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
url: `data:${file.mime};base64,${file.data}`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...(item.payload.agents ?? []).flatMap((agent) =>
|
||||
agent.mention
|
||||
? [
|
||||
{
|
||||
type: "agent" as const,
|
||||
content: agent.mention.text,
|
||||
start: agent.mention.start,
|
||||
end: agent.mention.end,
|
||||
name: agent.name,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...(item.payload.skills ?? []).flatMap((skill) =>
|
||||
skill.mention
|
||||
? [
|
||||
{
|
||||
type: "skill" as const,
|
||||
content: skill.mention.text,
|
||||
start: skill.mention.start,
|
||||
end: skill.mention.end,
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
].sort((left, right) => left.start - right.start)
|
||||
if (
|
||||
references.some(
|
||||
(part, index) =>
|
||||
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
|
||||
)
|
||||
)
|
||||
return
|
||||
const parts: Prompt = references.flatMap((part, index) => {
|
||||
const start = references[index - 1]?.end ?? 0
|
||||
return [
|
||||
...(part.start > start
|
||||
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
|
||||
: []),
|
||||
part,
|
||||
]
|
||||
})
|
||||
const start = references.at(-1)?.end ?? 0
|
||||
return [
|
||||
...parts,
|
||||
...(text.length > start || !parts.length
|
||||
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
|
||||
: []),
|
||||
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
|
||||
]
|
||||
}
|
||||
|
||||
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
|
||||
return !file.mention && file.source.type === "inline"
|
||||
}
|
||||
|
||||
@@ -224,7 +224,12 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
@@ -66,7 +65,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
|
||||
const server = useServer()
|
||||
const detailsProject = createMemo(() => {
|
||||
const info = session.data.info()
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
return info ? server.ctx.projects.detailsForSession(info) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const btw = createSessionBtw(session)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName, errorMessage, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
@@ -319,11 +319,8 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
|
||||
)
|
||||
const project = createMemo(() => {
|
||||
if (props.session) return server.ctx.projects.forSession(props.session)
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session)
|
||||
return (
|
||||
projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project)
|
||||
)
|
||||
const value = directory()
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createMemo, Show, type JSX } from "solid-js"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
@@ -16,7 +16,6 @@ export function SessionSummaryPanel(props: {
|
||||
shown?: boolean
|
||||
mobile?: boolean
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
@@ -44,7 +43,7 @@ export function SessionSummaryPanel(props: {
|
||||
return (
|
||||
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
|
||||
<div>
|
||||
<ProjectSummaryCard project={props.project} avatar={props.avatar}>
|
||||
<ProjectSummaryCard project={props.project}>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { createUniqueId, Show, type ParentProps, type JSX } from "solid-js"
|
||||
import { createUniqueId, Show, type ParentProps } from "solid-js"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import "./summary.css"
|
||||
|
||||
export function ProjectSummaryCard(
|
||||
props: ParentProps<{
|
||||
project: Pick<Project, "name" | "worktree" | "icon"> & { id?: string }
|
||||
avatar?: JSX.Element
|
||||
}>,
|
||||
) {
|
||||
const settings = useSettings()
|
||||
@@ -26,13 +24,7 @@ export function ProjectSummaryCard(
|
||||
aria-controls={contentID}
|
||||
onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())}
|
||||
>
|
||||
{props.avatar ?? (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<ProjectIcon project={props.project} />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
|
||||
@@ -31,7 +31,6 @@ import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { createTimelineVirtualizer } from "./virtualizer"
|
||||
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
@@ -143,14 +142,14 @@ function MessageTimelineView(
|
||||
const session = props.session.data.info()
|
||||
const projects = server.ctx.sync.data.project
|
||||
return session
|
||||
? projectForSession(session, projects)
|
||||
? server.ctx.projects.detailsForSession(session)
|
||||
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
|
||||
})
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const avatarProject = createMemo(() => {
|
||||
const headerProject = createMemo(() => {
|
||||
const session = props.session.data.info()
|
||||
if (!session) return
|
||||
return projectForSession(session, server.ctx.projects.list()) ?? project()
|
||||
return server.ctx.projects.forSession(session)
|
||||
})
|
||||
createEffect(() => {
|
||||
const directory = project()?.worktree
|
||||
@@ -408,7 +407,7 @@ function MessageTimelineView(
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
|
||||
<SessionProjectMenu
|
||||
project={avatarProject()}
|
||||
project={headerProject()}
|
||||
directory={sessionDirectory()}
|
||||
workspace={workspaceSession()}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { IntegrationInfo } from "@opencode/client/promise"
|
||||
import { activeProviderAccount, providerAccounts } from "./accounts"
|
||||
|
||||
const integration = (connections: IntegrationInfo["connections"]): IntegrationInfo => ({
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [],
|
||||
connections,
|
||||
})
|
||||
|
||||
describe("provider accounts", () => {
|
||||
test("preserves the server's active-first credential order", () => {
|
||||
const value = integration([
|
||||
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
|
||||
{ type: "env", name: "OPENAI_API_KEY" },
|
||||
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
|
||||
])
|
||||
|
||||
expect(providerAccounts(value)).toEqual([
|
||||
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
|
||||
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
|
||||
])
|
||||
expect(activeProviderAccount(value)).toEqual({ type: "credential", id: "cred_work", label: "Work", method: "key" })
|
||||
})
|
||||
|
||||
test("returns no active account for environment-only integrations", () => {
|
||||
const value = integration([{ type: "env", name: "OPENAI_API_KEY" }])
|
||||
|
||||
expect(providerAccounts(value)).toEqual([])
|
||||
expect(activeProviderAccount(value)).toBeUndefined()
|
||||
expect(providerAccounts(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ConnectionInfo, IntegrationInfo } from "@opencode/client/promise"
|
||||
|
||||
export type ProviderAccount = Extract<ConnectionInfo, { type: "credential" }>
|
||||
|
||||
export function providerAccounts(integration: IntegrationInfo | undefined) {
|
||||
return integration?.connections.filter((connection): connection is ProviderAccount => connection.type === "credential") ?? []
|
||||
}
|
||||
|
||||
export function activeProviderAccount(integration: IntegrationInfo | undefined) {
|
||||
return providerAccounts(integration)[0]
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Button } from "@opencode/ui/button"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { popularProviders, useProviders } from "@/providers/catalog/providers"
|
||||
@@ -16,6 +17,7 @@ import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/cont
|
||||
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
|
||||
import { ProviderModelIcon } from "@/providers/models/provider-group"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { activeProviderAccount, providerAccounts, type ProviderAccount } from "./accounts"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type ProviderSource = "env" | "api" | "account" | "config" | "custom"
|
||||
@@ -47,6 +49,7 @@ export const SettingsProviders: Component<{
|
||||
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
|
||||
consoleExpanded: false,
|
||||
connecting: false,
|
||||
credentialID: undefined as string | undefined,
|
||||
})
|
||||
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
|
||||
setState("disconnecting", (current) => ({
|
||||
@@ -190,6 +193,8 @@ export const SettingsProviders: Component<{
|
||||
return currentSource !== "env" && currentSource !== "config"
|
||||
}
|
||||
|
||||
const canManageAccounts = (item: ProviderItem) => providerAccounts(integration(item)).length > 0
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const disconnect = async (item: ProviderItem, name: string) => {
|
||||
@@ -230,6 +235,132 @@ export const SettingsProviders: Component<{
|
||||
})
|
||||
}
|
||||
|
||||
const refreshAccounts = async () => {
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
data.location.integration.invalidate(location)
|
||||
data.location.provider.invalidate(location)
|
||||
data.location.model.invalidate(location)
|
||||
await Promise.all([
|
||||
data.location.integration.sync(location),
|
||||
data.location.provider.sync(location),
|
||||
data.location.model.sync(location),
|
||||
])
|
||||
}
|
||||
|
||||
const accountError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
}
|
||||
|
||||
const activate = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
|
||||
if (activeProviderAccount(integration(provider))?.id === account.id) return
|
||||
setState("credentialID", account.id)
|
||||
await serverSdk.api.credential
|
||||
.activate({ credentialID: account.id })
|
||||
.then(refreshAccounts)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.providers.account.switched.title", { provider: providerName }),
|
||||
description: language.t("settings.providers.account.switched.description", { account: account.label }),
|
||||
}),
|
||||
)
|
||||
.catch(accountError)
|
||||
.finally(() => setState("credentialID", undefined))
|
||||
}
|
||||
|
||||
const remove = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
|
||||
const final = providerAccounts(integration(provider)).length === 1
|
||||
setState("credentialID", account.id)
|
||||
await serverSdk.api.credential
|
||||
.remove({ credentialID: account.id })
|
||||
.then(refreshAccounts)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t(
|
||||
final ? "provider.disconnect.toast.disconnected.title" : "settings.providers.account.removed.title",
|
||||
final ? { provider: providerName } : { account: account.label },
|
||||
),
|
||||
description: language.t(
|
||||
final
|
||||
? "provider.disconnect.toast.disconnected.description"
|
||||
: "settings.providers.account.removed.description",
|
||||
{ provider: providerName },
|
||||
),
|
||||
}),
|
||||
)
|
||||
.catch(accountError)
|
||||
.finally(() => setState("credentialID", undefined))
|
||||
}
|
||||
|
||||
function AccountMenu(menuProps: { provider: ProviderItem; name?: string }) {
|
||||
const accounts = () => providerAccounts(integration(menuProps.provider))
|
||||
const active = () => activeProviderAccount(integration(menuProps.provider))
|
||||
const name = () => menuProps.name ?? menuProps.provider.name
|
||||
|
||||
return (
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger
|
||||
as={Button}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
class="settings-provider-account-trigger"
|
||||
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
|
||||
>
|
||||
<span>{active()?.label}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
|
||||
<Menu.RadioGroup
|
||||
class="settings-provider-account-list"
|
||||
value={active()?.id}
|
||||
onChange={(credentialID) => {
|
||||
const account = accounts().find((item) => item.id === credentialID)
|
||||
if (account) void activate(menuProps.provider, name(), account)
|
||||
}}
|
||||
>
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
|
||||
<span class="settings-provider-account-label">{account.label}</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Group>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
|
||||
{language.t("settings.providers.account.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Sub placement="left-start">
|
||||
<Menu.SubTrigger disabled={state.credentialID !== undefined || accounts().length === 0}>
|
||||
{language.t("settings.providers.account.remove")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.SubContent class="settings-provider-account-submenu">
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Menu.Item
|
||||
badge={account.id === active()?.id ? language.t("settings.providers.account.active") : undefined}
|
||||
onSelect={() => void remove(menuProps.provider, name(), account)}
|
||||
>
|
||||
<span class="settings-provider-account-label">{account.label}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.SubContent>
|
||||
</Menu.Sub>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
@@ -268,22 +399,27 @@ export const SettingsProviders: Component<{
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
when={canManageAccounts(item)}
|
||||
fallback={
|
||||
<span class="settings-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
fallback={
|
||||
<span class="settings-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item, item.name)}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() =>
|
||||
void disconnect(item, item.name)
|
||||
}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
<AccountMenu provider={item} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
@@ -326,13 +462,20 @@ export const SettingsProviders: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
|
||||
<Show
|
||||
when={canManageAccounts(item)}
|
||||
fallback={
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
<AccountMenu provider={item} name={language.t("provider.connect.opencode.name")} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={state.consoleExpanded}>
|
||||
<div class="settings-provider-console-list">
|
||||
|
||||
@@ -887,6 +887,47 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-provider-account-trigger {
|
||||
min-width: 0;
|
||||
max-width: min(240px, 45%);
|
||||
}
|
||||
|
||||
.settings-provider-account-trigger > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-provider-account-menu[data-component="menu-v2-content"] {
|
||||
width: min(260px, calc(100vw - 32px));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.settings-provider-account-list {
|
||||
max-height: min(240px, calc(var(--kb-popper-content-available-height) - 120px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-provider-account-submenu[data-component="menu-v2-content"] {
|
||||
width: min(260px, calc(100vw - 32px));
|
||||
max-height: min(360px, var(--kb-popper-content-available-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-provider-account-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container settings-panel (max-width: 520px) {
|
||||
.settings-provider-account-trigger {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-providers-view-all {
|
||||
margin-top: 20px;
|
||||
padding: 0;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { displayName, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName, resolveProjectForSession } from "@/shell/layout/helpers"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
@@ -253,9 +253,7 @@ export function createServerSessionEntries(props: {
|
||||
})
|
||||
if (current.signal.aborted) return []
|
||||
const opened = props.opened()
|
||||
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
||||
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
||||
return Promise.all([
|
||||
props.load(search, current.signal).then(
|
||||
(result) => result.data,
|
||||
@@ -271,8 +269,7 @@ export function createServerSessionEntries(props: {
|
||||
[...new Map([...exact, ...listed].map((session) => [session.id, session] as const)).values()]
|
||||
.filter((session) => !session.time.archived)
|
||||
.map((session) => {
|
||||
const project =
|
||||
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
||||
const project = resolveProjectForSession(session, opened, stored)
|
||||
return {
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
|
||||
@@ -50,6 +50,17 @@ export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID:
|
||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||
project.name || getFilename(project.worktree) || project.worktree
|
||||
|
||||
type ProjectAppearance = {
|
||||
name?: string
|
||||
worktree: string
|
||||
icon?: { color?: string; url?: string; override?: string }
|
||||
}
|
||||
|
||||
function withProjectAppearance<T extends ProjectAppearance>(metadata: T, appearance?: ProjectAppearance) {
|
||||
if (!appearance || appearance === metadata) return metadata
|
||||
return { ...metadata, name: displayName(appearance), icon: appearance.icon }
|
||||
}
|
||||
|
||||
export function toggleHomeProjectSelection(
|
||||
current: HomeProjectSelection | undefined,
|
||||
server: ServerConnection.Key,
|
||||
@@ -97,11 +108,59 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
|
||||
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
|
||||
session: SessionInfo,
|
||||
projects: T[],
|
||||
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
) {
|
||||
const direct = byID.get(session.projectID)
|
||||
if (direct) return direct
|
||||
return projects.find((project) => isProjectDirectory(project, session.location.directory))
|
||||
const matching = projects.filter((project) => project.id === session.projectID)
|
||||
if (matching.length === 1) return matching[0]
|
||||
if (matching.length > 1) {
|
||||
const directory = pathKey(session.location.directory)
|
||||
const exact =
|
||||
matching.find((project) => pathKey(project.worktree) === directory) ??
|
||||
matching.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory))
|
||||
if (exact) return exact
|
||||
return (
|
||||
matching
|
||||
.filter((project) => isProjectDirectory(project, session.location.directory))
|
||||
.sort((a, b) => b.worktree.length - a.worktree.length)[0] ?? matching.at(-1)
|
||||
)
|
||||
}
|
||||
const directory = pathKey(session.location.directory)
|
||||
const exact =
|
||||
projects.find((project) => pathKey(project.worktree) === directory) ??
|
||||
projects.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory))
|
||||
if (exact) return exact
|
||||
return projects
|
||||
.filter((project) => isProjectDirectory(project, session.location.directory))
|
||||
.sort((a, b) => b.worktree.length - a.worktree.length)[0]
|
||||
}
|
||||
|
||||
export function resolveProjectForSession<
|
||||
T extends { id?: string; worktree: string; sandboxes?: string[] },
|
||||
U extends { id?: string; worktree: string; sandboxes?: string[] },
|
||||
>(session: SessionInfo, opened: T[], stored: U[]) {
|
||||
const current = projectForSession(session, opened)
|
||||
if (current?.id === session.projectID) {
|
||||
const unresolved = opened.find(
|
||||
(project) => !project.id && pathKey(project.worktree) === pathKey(session.location.directory),
|
||||
)
|
||||
if (!unresolved) return current
|
||||
const canonical = projectForSession(session, stored)
|
||||
if (canonical?.id === session.projectID && pathKey(canonical.worktree) === pathKey(unresolved.worktree))
|
||||
return unresolved
|
||||
return current
|
||||
}
|
||||
const synced = projectForSession(session, stored)
|
||||
if (synced?.id !== session.projectID) return current ?? synced
|
||||
if (current && !current.id && pathKey(current.worktree) === pathKey(session.location.directory)) return current
|
||||
return synced
|
||||
}
|
||||
|
||||
export function resolveSessionDetailsProject<
|
||||
T extends ProjectAppearance & { id?: string; sandboxes?: string[] },
|
||||
U extends ProjectAppearance & { id?: string; sandboxes?: string[] },
|
||||
>(session: SessionInfo, opened: T[], stored: U[]) {
|
||||
const metadata = projectForSession(session, stored)
|
||||
if (!metadata) return
|
||||
return withProjectAppearance(metadata, resolveProjectForSession(session, opened, stored))
|
||||
}
|
||||
|
||||
export const errorMessage = (err: unknown, fallback: string) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
|
||||
|
||||
type ProjectIconProps = Omit<ProjectAvatarProps, "fallback" | "src" | "variant"> & {
|
||||
project: LocalProject
|
||||
project: Pick<LocalProject, "id" | "name" | "worktree" | "icon">
|
||||
fallback?: string
|
||||
icon?: LocalProject["icon"]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Menu } from "@opencode/ui/menu"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { displayName, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
|
||||
const project = createMemo(() => {
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
return serverCtx()?.projects.forSession(session)
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session
|
||||
|
||||
@@ -29,7 +29,6 @@ import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigg
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { updaterAction } from "@/shell/updates/action"
|
||||
import type { UpdaterState } from "@/shell/updates/types"
|
||||
@@ -441,7 +440,7 @@ export function Titlebar(props: {
|
||||
const value = session()
|
||||
if (!tab || !value) return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||
return projectForSession(value, conn ? global.ensureServerCtx(conn).projects.list() : [])
|
||||
return conn ? global.ensureServerCtx(conn).projects.forSession(value) : undefined
|
||||
})
|
||||
const currentTitle = () => {
|
||||
const tab = currentTab()
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"build:node": "bun run script/build-node.ts",
|
||||
"dev": "bun run src/index.ts",
|
||||
"test": "bun test --timeout 30000 --only-failures",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "1.2.1",
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
|
||||
"noUncheckedIndexedAccess": false
|
||||
},
|
||||
"exclude": ["dist", "dist-node"]
|
||||
"exclude": ["dist", "dist-node"],
|
||||
"references": [{ "path": "../core" }]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user