mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-28 06:02:05 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edf0ce766d |
@@ -132,14 +132,18 @@
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0",
|
||||
},
|
||||
@@ -896,13 +900,9 @@
|
||||
"version": "1.17.13",
|
||||
"dependencies": {
|
||||
"@fontsource/commit-mono": "5.2.5",
|
||||
"@fontsource/noto-sans-math": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols": "5.2.5",
|
||||
"@fontsource/noto-sans-symbols-2": "5.2.5",
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@opencode-ai/ai": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -1026,7 +1026,6 @@
|
||||
"@opencode-ai/client": "workspace:*",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@opencode-ai/simulation": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -1796,12 +1795,6 @@
|
||||
|
||||
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
|
||||
|
||||
"@fontsource/noto-sans-math": ["@fontsource/noto-sans-math@5.2.5", "", {}, "sha512-1bxEvVlF51Vfgpju32mRZzI/CHvsfqjXjI2+sAuEyHYvXABUAIyj+93sCO3QZIoMG5drWyrzgoCqRQRaL6wQ8Q=="],
|
||||
|
||||
"@fontsource/noto-sans-symbols": ["@fontsource/noto-sans-symbols@5.2.5", "", {}, "sha512-mxoIRstsmZpZFzd/SRWiD+l6T7TGhpgCrGs7TEnnuGSQIfjVMrQT9Zej2enh9pkfmPNAFyeaGJkHkszJ1hH++w=="],
|
||||
|
||||
"@fontsource/noto-sans-symbols-2": ["@fontsource/noto-sans-symbols-2@5.2.5", "", {}, "sha512-F4O9WLifwoZS1quNzY1ebjMNo2cQPe/UP68Dmud0ONi2lOxaR6xp6fFPO2gG17MI7DwAnfMyQFl64A2tAd28hg=="],
|
||||
|
||||
"@fuma-translate/react": ["@fuma-translate/react@1.0.2", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-uOiOtBx3nRXR8Nu1GzBf1tApgF1FErDBTHxRIAQeyQdyOoZbrNRN6H4kDCWObY4qyGeGbHydG0DHzgeUgFDMIw=="],
|
||||
|
||||
"@fumadocs/tailwind": ["@fumadocs/tailwind@0.1.0", "", { "peerDependencies": { "tailwindcss": "^4.0.0" }, "optionalPeers": ["tailwindcss"] }, "sha512-nF/DCAwOR21HZ4AkjIOv3Iqwyqywzb6pdyeMcoa+aZzirXj5ntvNZbe3jJ0v3ehhtrRfYYeXBezvjn8ZmV+fuQ=="],
|
||||
|
||||
+2
-43
@@ -1,6 +1,6 @@
|
||||
# @opencode-ai/ai
|
||||
|
||||
Schema-first AI primitives for opencode. Provider quirks live in adapters, not in calling code.
|
||||
Schema-first LLM core for opencode. One typed request, response, event, and tool language; provider quirks live in adapters, not in calling code.
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
@@ -24,45 +24,6 @@ const program = Effect.gen(function* () {
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
## Image generation
|
||||
|
||||
Use `Image.generate` with an image model for direct asset generation:
|
||||
|
||||
```ts
|
||||
import { Image } from "@opencode-ai/ai"
|
||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
count: 2,
|
||||
size: { width: 1024, height: 1024 },
|
||||
providerOptions: { openai: { quality: "high", outputFormat: "webp" } },
|
||||
})
|
||||
|
||||
return response.images // GeneratedImage[] with owned bytes or a provider URL
|
||||
})
|
||||
```
|
||||
|
||||
Conversational image generation remains part of the LLM interaction. OpenAI Responses exposes it through its hosted image tool:
|
||||
|
||||
```ts
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey }).responses("gpt-5"),
|
||||
prompt: "Design a solarpunk rooftop garden, then show me.",
|
||||
tools: [OpenAI.imageGeneration({ quality: "high" })],
|
||||
}),
|
||||
)
|
||||
|
||||
return response.message
|
||||
})
|
||||
```
|
||||
|
||||
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
|
||||
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
@@ -71,8 +32,6 @@ The hosted result is represented as a provider-executed tool call and tool resul
|
||||
- **`Model.make(...)` / `ToolCallPart.make(...)` / `ToolResultPart.make(...)` / `ToolDefinition.make(...)`** — model and tool-related constructors from the canonical schema model.
|
||||
- **`LLMClient.prepare(request)`** — compile a request through protocol body construction, validation, and HTTP preparation without sending. Useful for inspection and testing.
|
||||
- **`LLMEvent.is.*`** — typed guards (`is.textDelta`, `is.toolCall`, `is.finish`, …) for filtering streams.
|
||||
- **`Image.generate({...})`** — generate images through a provider-neutral image request and response model.
|
||||
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||
|
||||
## Caching
|
||||
|
||||
@@ -223,7 +182,7 @@ Adding a new model or deployment is usually 5-15 lines using `Route.make({ proto
|
||||
|
||||
## Effect
|
||||
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for runtime dispatch and import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# LLM Provider Parity Status
|
||||
|
||||
Last reviewed: 2026-07-17
|
||||
Last reviewed: 2026-07-16
|
||||
|
||||
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
|
||||
|
||||
@@ -20,7 +20,7 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
|
||||
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
||||
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
|
||||
| OpenAI-compatible Responses | `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the OpenAI Responses wire protocol. | No named family profiles or recorded deployment coverage yet. |
|
||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
|
||||
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base. | No named compatible family profiles or recorded deployment coverage yet. |
|
||||
| Anthropic Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic.ts` | Usable. Supports tools, thinking, cache control, images, server-hosted tool events, and usage. | Provider option surface is small. Beta/header handling, metadata, and newer Messages fields need a typed parity pass. |
|
||||
| Gemini Developer API | `src/protocols/gemini.ts`, `src/providers/google.ts` | Usable for Google API key flow. Supports text, images, tools, thinking signatures, and cache usage. | This is not Vertex. Typed provider options are narrow; many Gemini request fields currently require raw `http.body` overlays. |
|
||||
| Vertex Gemini | `src/protocols/gemini.ts`, `src/providers/google-vertex.ts` | Usable through API-key express mode, explicit OAuth tokens, or ADC with project/location endpoint derivation, including tuned `endpoints/...` deployments. | Core runner/catalog mapping and recorded provider coverage are missing. |
|
||||
|
||||
@@ -161,18 +161,6 @@ const PROVIDERS: ReadonlyArray<Provider> = [
|
||||
vars: [{ name: "TOGETHER_AI_API_KEY" }],
|
||||
validate: (env) => validateBearer("https://api.together.xyz/v1/models", Redacted.make(env.TOGETHER_AI_API_KEY)),
|
||||
},
|
||||
{
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
tier: "compatible",
|
||||
note: "Anthropic-compatible Messages text/tool recorded tests",
|
||||
vars: [{ name: "MINIMAX_API_KEY" }],
|
||||
validate: (env) =>
|
||||
HttpClientRequest.get("https://api.minimax.io/anthropic/v1/models").pipe(
|
||||
HttpClientRequest.setHeader("x-api-key", Redacted.value(Redacted.make(env.MINIMAX_API_KEY))),
|
||||
executeRequest,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "mistral",
|
||||
label: "Mistral",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { RequestExecutor } from "./route/executor"
|
||||
import type { ImageRequest, ImageResponse } from "./image"
|
||||
import type { LLMError } from "./schema"
|
||||
|
||||
export type Execute = RequestExecutor.Interface["execute"]
|
||||
|
||||
export interface Interface {
|
||||
readonly generate: (request: ImageRequest) => Effect.Effect<ImageResponse, LLMError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||
|
||||
export const generate = (request: ImageRequest): Effect.Effect<ImageResponse, LLMError> =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* Service
|
||||
return yield* client.generate(request)
|
||||
}) as Effect.Effect<ImageResponse, LLMError>
|
||||
|
||||
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),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const ImageClient = {
|
||||
Service,
|
||||
layer,
|
||||
generate,
|
||||
} as const
|
||||
@@ -1,116 +0,0 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpOptions, InvalidRequestReason, LLMError, ModelID, ProviderID, ProviderMetadata, Usage } from "./schema"
|
||||
import { ImageClient, type Execute as ImageExecute } from "./image-client"
|
||||
|
||||
export interface ImageRoute {
|
||||
readonly id: string
|
||||
readonly generate: (request: ImageRequest, execute: ImageExecute) => Effect.Effect<ImageResponse, LLMError>
|
||||
}
|
||||
|
||||
export class ImageModel {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute
|
||||
readonly defaults?: ImageModelDefaults
|
||||
|
||||
constructor(input: ImageModel.Input) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.defaults = input.defaults
|
||||
}
|
||||
|
||||
static make(input: ImageModel.MakeInput) {
|
||||
return new ImageModel({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
defaults: input.defaults,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace ImageModel {
|
||||
export interface Input {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: ImageRoute
|
||||
readonly defaults?: ImageModelDefaults
|
||||
}
|
||||
|
||||
export interface MakeInput extends Omit<Input, "id" | "provider"> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
export interface ImageModelDefaults {
|
||||
readonly providerOptions?: Record<string, Record<string, unknown>>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export const ImageModelSchema = Schema.declare((value): value is ImageModel => value instanceof ImageModel, {
|
||||
expected: "Image.Model",
|
||||
})
|
||||
|
||||
export const ImageSize = Schema.Struct({
|
||||
width: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
|
||||
height: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
|
||||
}).annotate({ identifier: "Image.Size" })
|
||||
export type ImageSize = Schema.Schema.Type<typeof ImageSize>
|
||||
|
||||
export class ImageRequest extends Schema.Class<ImageRequest>("Image.Request")({
|
||||
model: ImageModelSchema,
|
||||
prompt: Schema.String,
|
||||
count: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
||||
size: Schema.optional(ImageSize),
|
||||
aspectRatio: Schema.optional(Schema.String),
|
||||
seed: Schema.optional(Schema.Number),
|
||||
providerOptions: Schema.optional(Schema.Record(Schema.String, Schema.Record(Schema.String, Schema.Unknown))),
|
||||
http: Schema.optional(HttpOptions),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}) {}
|
||||
|
||||
export type ImageRequestInput = Omit<ConstructorParameters<typeof ImageRequest>[0], "http"> & {
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
export class GeneratedImage extends Schema.Class<GeneratedImage>("Image.Generated")({
|
||||
mediaType: Schema.String,
|
||||
data: Schema.Union([Schema.String, Schema.Uint8Array]),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export class ImageResponse extends Schema.Class<ImageResponse>("Image.Response")({
|
||||
images: Schema.Array(GeneratedImage),
|
||||
usage: Schema.optional(Usage),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {
|
||||
get image() {
|
||||
return this.images[0]
|
||||
}
|
||||
}
|
||||
|
||||
export const request = (input: ImageRequest | ImageRequestInput) => {
|
||||
if (input instanceof ImageRequest) return input
|
||||
return new ImageRequest({
|
||||
...input,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
}
|
||||
|
||||
export const generate = (input: ImageRequest | ImageRequestInput) =>
|
||||
Effect.try({
|
||||
try: () => request(input),
|
||||
catch: (error) =>
|
||||
new LLMError({
|
||||
module: "Image",
|
||||
method: "generate",
|
||||
reason: new InvalidRequestReason({ message: error instanceof Error ? error.message : String(error) }),
|
||||
}),
|
||||
}).pipe(Effect.flatMap(ImageClient.generate))
|
||||
|
||||
export const Image = {
|
||||
request,
|
||||
generate,
|
||||
} as const
|
||||
@@ -1,5 +1,4 @@
|
||||
export { LLMClient } from "./route/client"
|
||||
export { ImageClient } from "./image-client"
|
||||
export { Auth } from "./route/auth"
|
||||
export { Provider } from "./provider"
|
||||
export { ProviderPackage } from "./provider-package"
|
||||
@@ -11,9 +10,6 @@ export type {
|
||||
Service as LLMClientService,
|
||||
} from "./route/client"
|
||||
export * from "./schema"
|
||||
export { GeneratedImage, ImageModel, ImageRequest, ImageResponse, ImageSize } from "./image"
|
||||
export type { ImageModelDefaults, ImageRequestInput, ImageRoute } from "./image"
|
||||
export { Image } from "./image"
|
||||
export { Tool, ToolFailure, toDefinitions } from "./tool"
|
||||
export { ToolRuntime } from "./tool-runtime"
|
||||
export type { DispatchResult as ToolDispatchResult, ToolSettlement } from "./tool-runtime"
|
||||
|
||||
@@ -703,14 +703,7 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
providerExecuted: block.type === "server_tool_use",
|
||||
}),
|
||||
},
|
||||
[
|
||||
...events,
|
||||
LLMEvent.toolInputStart({
|
||||
id: block.id ?? String(event.index),
|
||||
name: block.name ?? "",
|
||||
providerExecuted: block.type === "server_tool_use" ? true : undefined,
|
||||
}),
|
||||
],
|
||||
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -561,9 +561,7 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
hasToolCalls:
|
||||
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasToolCalls,
|
||||
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
|
||||
lifecycle,
|
||||
tools: result.tools,
|
||||
reasoningSignatures: Object.fromEntries(
|
||||
|
||||
@@ -2,7 +2,6 @@ export * as AnthropicMessages from "./anthropic-messages"
|
||||
export * as BedrockConverse from "./bedrock-converse"
|
||||
export * as Gemini from "./gemini"
|
||||
export * as OpenAIChat from "./openai-chat"
|
||||
export * as OpenAIImages from "./openai-images"
|
||||
export * as OpenAICompatibleChat from "./openai-compatible-chat"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses"
|
||||
export * as OpenAIResponses from "./openai-responses"
|
||||
|
||||
@@ -75,9 +75,6 @@ const OpenAIChatMessage = Schema.Union([
|
||||
content: Schema.NullOr(Schema.String),
|
||||
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
|
||||
reasoning_content: Schema.optional(Schema.String),
|
||||
reasoning: Schema.optional(Schema.String),
|
||||
reasoning_text: Schema.optional(Schema.String),
|
||||
reasoning_details: optionalArray(Schema.Unknown),
|
||||
}),
|
||||
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
@@ -148,9 +145,6 @@ type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta
|
||||
const OpenAIChatDelta = Schema.Struct({
|
||||
content: optionalNull(Schema.String),
|
||||
reasoning_content: optionalNull(Schema.String),
|
||||
reasoning: optionalNull(Schema.String),
|
||||
reasoning_text: optionalNull(Schema.String),
|
||||
reasoning_details: optionalNull(Schema.Array(Schema.Unknown)),
|
||||
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
|
||||
})
|
||||
|
||||
@@ -166,23 +160,12 @@ export const OpenAIChatEvent = Schema.Struct({
|
||||
export type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
|
||||
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
|
||||
|
||||
interface PendingToolDelta {
|
||||
readonly id?: string
|
||||
readonly name?: string
|
||||
readonly input: string
|
||||
}
|
||||
|
||||
export interface ParserState {
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly pendingTools: Partial<Record<number, PendingToolDelta>>
|
||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReason
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningField?: "reasoning" | "reasoning_content" | "reasoning_text"
|
||||
readonly reasoningDetails: Array<unknown>
|
||||
readonly reasoningDetailsObserved: boolean
|
||||
readonly reasoningEmitted: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -225,20 +208,6 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
|
||||
const openAICompatibleReasoningContent = (native: unknown) =>
|
||||
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
|
||||
|
||||
const reasoningField = (part: ReasoningPart) => {
|
||||
const field = part.providerMetadata?.openai?.reasoningField
|
||||
if (field === "reasoning" || field === "reasoning_content" || field === "reasoning_text") return field
|
||||
}
|
||||
|
||||
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown) => {
|
||||
const observed = parts.flatMap((part) => {
|
||||
const details = part.providerMetadata?.openai?.reasoningDetails
|
||||
return Array.isArray(details) ? details : []
|
||||
})
|
||||
if (parts.some((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))) return observed
|
||||
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
|
||||
}
|
||||
|
||||
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
|
||||
const content: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
|
||||
for (const part of message.content) {
|
||||
@@ -279,29 +248,14 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
|
||||
continue
|
||||
}
|
||||
}
|
||||
const text = reasoning.map((part) => part.text).join("")
|
||||
const details = reasoningDetails(reasoning, message.native?.openaiCompatible)
|
||||
const observedField = reasoning.map(reasoningField).find((value) => value !== undefined)
|
||||
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
|
||||
const fullyStructured = reasoning.every((part) => Array.isArray(part.providerMetadata?.openai?.reasoningDetails))
|
||||
const field = (() => {
|
||||
if (reasoning.length === 0) return
|
||||
if (observedField !== undefined) return observedField
|
||||
if (nativeReasoning !== undefined) return "reasoning_content"
|
||||
if (!fullyStructured) return "reasoning_content"
|
||||
})()
|
||||
const reasoningContent = (() => {
|
||||
if (reasoning.length === 0) return nativeReasoning
|
||||
if (field === "reasoning_content") return text
|
||||
})()
|
||||
return {
|
||||
role: "assistant" as const,
|
||||
content: content.length === 0 ? null : ProviderShared.joinText(content),
|
||||
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
|
||||
reasoning_content: reasoningContent,
|
||||
reasoning: reasoning.length > 0 && field === "reasoning" ? text : undefined,
|
||||
reasoning_text: reasoning.length > 0 && field === "reasoning_text" ? text : undefined,
|
||||
reasoning_details: details,
|
||||
reasoning_content:
|
||||
reasoning.length > 0
|
||||
? reasoning.map((part) => part.text).join("")
|
||||
: openAICompatibleReasoningContent(message.native?.openaiCompatible),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -446,65 +400,6 @@ const mapUsage = (usage: OpenAIChatEvent["usage"]): Usage | undefined => {
|
||||
})
|
||||
}
|
||||
|
||||
const reasoningDelta = (delta: Schema.Schema.Type<typeof OpenAIChatDelta> | null | undefined) => {
|
||||
if (delta?.reasoning_content) return { field: "reasoning_content", text: delta.reasoning_content } as const
|
||||
if (delta?.reasoning) return { field: "reasoning", text: delta.reasoning } as const
|
||||
if (delta?.reasoning_text) return { field: "reasoning_text", text: delta.reasoning_text } as const
|
||||
}
|
||||
|
||||
const detailText = (details: ReadonlyArray<unknown>) => {
|
||||
const text = details.flatMap((detail) => {
|
||||
if (!isRecord(detail)) return []
|
||||
if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text) return [detail.text]
|
||||
if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
|
||||
return [detail.summary]
|
||||
return []
|
||||
})
|
||||
if (text.length > 0) return text.join("")
|
||||
}
|
||||
|
||||
const appendReasoningDetails = (result: Array<unknown>, details: ReadonlyArray<unknown>) => {
|
||||
for (const detail of details) {
|
||||
const previous = result.at(-1)
|
||||
if (
|
||||
!isRecord(previous) ||
|
||||
previous.type !== "reasoning.text" ||
|
||||
!isRecord(detail) ||
|
||||
detail.type !== "reasoning.text" ||
|
||||
conflictingReasoningTextDetails(previous, detail)
|
||||
) {
|
||||
result.push(detail)
|
||||
continue
|
||||
}
|
||||
result[result.length - 1] = {
|
||||
...previous,
|
||||
...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
|
||||
text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
|
||||
signature: mergeDetailValue(previous.signature, detail.signature),
|
||||
format: mergeDetailValue(previous.format, detail.format),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mergeDetailValue = (previous: unknown, current: unknown) =>
|
||||
previous || current || (previous !== undefined ? previous : current)
|
||||
|
||||
const conflictingReasoningTextDetails = (previous: Record<string, unknown>, current: Record<string, unknown>) =>
|
||||
conflictingDetailValue(previous.id, current.id) ||
|
||||
conflictingDetailValue(previous.index, current.index) ||
|
||||
conflictingDetailValue(previous.format, current.format) ||
|
||||
(Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature)
|
||||
|
||||
const conflictingDetailValue = (previous: unknown, current: unknown) =>
|
||||
previous !== undefined && previous !== null && current !== undefined && current !== null && previous !== current
|
||||
|
||||
const reasoningMetadata = (field: ParserState["reasoningField"], details?: ReadonlyArray<unknown>) => ({
|
||||
openai: {
|
||||
...(field ? { reasoningField: field } : {}),
|
||||
...(details ? { reasoningDetails: details } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
Effect.gen(function* () {
|
||||
const events: LLMEvent[] = []
|
||||
@@ -514,56 +409,25 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const delta = choice?.delta
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
let pendingTools = state.pendingTools
|
||||
|
||||
let lifecycle = state.lifecycle
|
||||
|
||||
const reasoning = reasoningDelta(delta)
|
||||
const reasoningField = state.reasoningField ?? (!state.lifecycle.text.has("text-0") ? reasoning?.field : undefined)
|
||||
const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined
|
||||
if (detailDelta !== undefined) appendReasoningDetails(state.reasoningDetails, detailDelta)
|
||||
const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined
|
||||
const deltaMetadata = reasoningMetadata(reasoningField)
|
||||
const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text
|
||||
if (!state.lifecycle.text.has("text-0") && text !== undefined)
|
||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata)
|
||||
else if (
|
||||
reasoningDetailsObserved &&
|
||||
!lifecycle.reasoning.has("reasoning-0") &&
|
||||
(Boolean(delta?.content) || toolDeltas.length > 0)
|
||||
)
|
||||
lifecycle = Lifecycle.reasoningStart(lifecycle, events, "reasoning-0", deltaMetadata)
|
||||
const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0")
|
||||
if (delta?.reasoning_content)
|
||||
lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", delta.reasoning_content)
|
||||
|
||||
if (delta?.content) {
|
||||
lifecycle = Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
reasoningMetadata(reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined),
|
||||
)
|
||||
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
||||
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
}
|
||||
|
||||
if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0")
|
||||
|
||||
for (const tool of toolDeltas) {
|
||||
const current = tools[tool.index]
|
||||
const pending = pendingTools[tool.index]
|
||||
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
|
||||
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
|
||||
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
|
||||
if (!current && (!id || !name)) {
|
||||
pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } }
|
||||
continue
|
||||
}
|
||||
if (pending) {
|
||||
pendingTools = { ...pendingTools }
|
||||
delete pendingTools[tool.index]
|
||||
}
|
||||
const result = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
tools,
|
||||
tool.index,
|
||||
{ id: id || undefined, name: name || undefined, text },
|
||||
{ id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" },
|
||||
"OpenAI Chat tool call delta is missing id or name",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
@@ -572,11 +436,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0)
|
||||
return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name")
|
||||
|
||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||
// valid calls and malformed local calls settle independently.
|
||||
// JSON parse failures fail the stream at the boundary rather than at halt.
|
||||
const finished =
|
||||
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
|
||||
? yield* ToolStream.finishAll(ADAPTER, tools)
|
||||
@@ -585,15 +446,10 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
return [
|
||||
{
|
||||
tools: finished?.tools ?? tools,
|
||||
pendingTools,
|
||||
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
||||
usage,
|
||||
finishReason,
|
||||
lifecycle,
|
||||
reasoningField,
|
||||
reasoningDetails: state.reasoningDetails,
|
||||
reasoningDetailsObserved,
|
||||
reasoningEmitted,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
@@ -603,16 +459,7 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const events: LLMEvent[] = []
|
||||
const hasToolCalls = state.toolCallEvents.length > 0
|
||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||
const metadata = reasoningMetadata(
|
||||
state.reasoningField,
|
||||
state.reasoningDetailsObserved ? state.reasoningDetails : undefined,
|
||||
)
|
||||
const started =
|
||||
state.reasoningDetailsObserved && !state.reasoningEmitted
|
||||
? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.reasoningField))
|
||||
: state.lifecycle
|
||||
const ended = Lifecycle.reasoningEnd(started, events, "reasoning-0", metadata)
|
||||
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(ended, events) : ended
|
||||
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...state.toolCallEvents)
|
||||
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||
return events
|
||||
@@ -635,16 +482,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
pendingTools: {},
|
||||
toolCallEvents: [],
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningField: undefined,
|
||||
reasoningDetails: [],
|
||||
reasoningDetailsObserved: false,
|
||||
reasoningEmitted: false,
|
||||
}),
|
||||
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
|
||||
step,
|
||||
onHalt: finishEvents,
|
||||
},
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
ImageModel,
|
||||
GeneratedImage,
|
||||
ImageResponse,
|
||||
type ImageRequest,
|
||||
type ImageModelDefaults,
|
||||
type ImageRoute,
|
||||
} from "../image"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth"
|
||||
import { InvalidProviderOutputReason, LLMError, Usage, mergeHttpOptions, mergeJsonRecords } from "../schema"
|
||||
import { ProviderShared } from "./shared"
|
||||
import { OpenAIImage } from "./utils/openai-image"
|
||||
|
||||
const ADAPTER = "openai-images"
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = "/images/generations"
|
||||
|
||||
export interface OpenAIImageOptions {
|
||||
readonly quality?: "auto" | "low" | "medium" | "high"
|
||||
readonly background?: "auto" | "opaque" | "transparent"
|
||||
readonly moderation?: "auto" | "low"
|
||||
readonly outputFormat?: "png" | "jpeg" | "webp"
|
||||
readonly outputCompression?: number
|
||||
}
|
||||
|
||||
const OpenAIImageBody = Schema.Struct({
|
||||
model: Schema.String,
|
||||
prompt: Schema.String,
|
||||
n: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
|
||||
size: Schema.optional(Schema.String),
|
||||
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
|
||||
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
|
||||
moderation: Schema.optional(Schema.Literals(["auto", "low"])),
|
||||
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
|
||||
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
|
||||
})
|
||||
export type OpenAIImageBody = Schema.Schema.Type<typeof OpenAIImageBody>
|
||||
|
||||
const OpenAIImageResponse = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
Schema.Struct({
|
||||
b64_json: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
revised_prompt: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
output_format: Schema.optional(Schema.String),
|
||||
usage: Schema.optional(
|
||||
Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
input_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export interface ModelInput {
|
||||
readonly id: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly defaults?: ImageModelDefaults
|
||||
}
|
||||
|
||||
const providerOptions = (request: ImageRequest): OpenAIImageOptions => ({
|
||||
...request.model.defaults?.providerOptions?.openai,
|
||||
...request.providerOptions?.openai,
|
||||
})
|
||||
|
||||
const body = (request: ImageRequest): OpenAIImageBody => {
|
||||
const options = providerOptions(request)
|
||||
return {
|
||||
model: request.model.id,
|
||||
prompt: request.prompt,
|
||||
n: request.count,
|
||||
size: request.size === undefined ? undefined : `${request.size.width}x${request.size.height}`,
|
||||
quality: options.quality,
|
||||
background: options.background,
|
||||
moderation: options.moderation,
|
||||
output_format: options.outputFormat,
|
||||
output_compression: options.outputCompression,
|
||||
}
|
||||
}
|
||||
|
||||
const invalidOutput = (message: string) =>
|
||||
new LLMError({
|
||||
module: ADAPTER,
|
||||
method: "generate",
|
||||
reason: new InvalidProviderOutputReason({ message, route: ADAPTER }),
|
||||
})
|
||||
|
||||
const applyQuery = (url: string, query: Record<string, string> | undefined) => {
|
||||
if (!query) return url
|
||||
const next = new URL(url)
|
||||
Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value))
|
||||
return next.toString()
|
||||
}
|
||||
|
||||
const PROTOCOL_BODY_FIELDS = new Set([
|
||||
"model",
|
||||
"prompt",
|
||||
"n",
|
||||
"size",
|
||||
"quality",
|
||||
"background",
|
||||
"moderation",
|
||||
"output_format",
|
||||
"output_compression",
|
||||
])
|
||||
|
||||
const bodyWithOverlay = Effect.fn("OpenAIImages.bodyWithOverlay")(function* (
|
||||
imageBody: OpenAIImageBody,
|
||||
overlay: Record<string, unknown> | undefined,
|
||||
) {
|
||||
if (!overlay) return imageBody
|
||||
const reserved = Object.keys(overlay).filter((key) => PROTOCOL_BODY_FIELDS.has(key))
|
||||
if (reserved.length > 0)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`http.body cannot overlay protocol-owned field(s): ${reserved.join(", ")}`,
|
||||
)
|
||||
return mergeJsonRecords(imageBody, overlay) ?? imageBody
|
||||
})
|
||||
|
||||
export const model = (input: ModelInput) => {
|
||||
const route: ImageRoute = {
|
||||
id: ADAPTER,
|
||||
generate: Effect.fn("OpenAIImages.generate")(function* (request: ImageRequest, execute) {
|
||||
if (request.aspectRatio !== undefined)
|
||||
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common aspectRatio option")
|
||||
if (request.seed !== undefined)
|
||||
return yield* ProviderShared.invalidRequest("OpenAI Images does not support the common seed option")
|
||||
|
||||
const requestBody = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIImageBody))(body(request))
|
||||
const http = mergeHttpOptions(request.model.defaults?.http, request.http)
|
||||
const overlaidBody = yield* bodyWithOverlay(requestBody, http?.body)
|
||||
const text = ProviderShared.encodeJson(overlaidBody)
|
||||
const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${PATH}`, http?.query)
|
||||
const headers = yield* Auth.toEffect(input.auth)({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: text,
|
||||
headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
|
||||
})
|
||||
const response = yield* execute(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(text, "application/json"),
|
||||
),
|
||||
)
|
||||
const payload = yield* response.json.pipe(
|
||||
Effect.mapError(() => invalidOutput("Failed to read the OpenAI Images response")),
|
||||
)
|
||||
const decoded = yield* Schema.decodeUnknownEffect(OpenAIImageResponse)(payload).pipe(
|
||||
Effect.mapError(() => invalidOutput("OpenAI Images returned an invalid response")),
|
||||
)
|
||||
const format = decoded.output_format ?? providerOptions(request).outputFormat ?? "png"
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) => {
|
||||
if (item.b64_json)
|
||||
return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(
|
||||
Effect.mapError(() => invalidOutput(`OpenAI Images result ${index} contains invalid base64 data`)),
|
||||
Effect.map(
|
||||
(data) =>
|
||||
new GeneratedImage({
|
||||
mediaType: `image/${format}`,
|
||||
data,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (item.url)
|
||||
return Effect.succeed(
|
||||
new GeneratedImage({
|
||||
mediaType: `image/${format}`,
|
||||
data: item.url,
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
}),
|
||||
)
|
||||
return Effect.fail(invalidOutput(`OpenAI Images result ${index} has neither image data nor a URL`))
|
||||
})
|
||||
if (images.length === 0) return yield* invalidOutput("OpenAI Images returned no images")
|
||||
return new ImageResponse({
|
||||
images,
|
||||
usage:
|
||||
decoded.usage === undefined
|
||||
? undefined
|
||||
: new Usage({
|
||||
inputTokens: decoded.usage.input_tokens,
|
||||
outputTokens: decoded.usage.output_tokens,
|
||||
totalTokens: decoded.usage.total_tokens,
|
||||
providerMetadata: { openai: decoded.usage },
|
||||
}),
|
||||
providerMetadata: { openai: { outputFormat: format } },
|
||||
})
|
||||
}),
|
||||
}
|
||||
return ImageModel.make({ id: input.id, provider: "openai", route, defaults: input.defaults })
|
||||
}
|
||||
|
||||
export const OpenAIImages = {
|
||||
model,
|
||||
} as const
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Route } from "../route/client"
|
||||
import { Auth } from "../route/auth"
|
||||
import { Endpoint } from "../route/endpoint"
|
||||
@@ -25,7 +25,6 @@ import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
import { OpenAIImage } from "./utils/openai-image"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
@@ -114,24 +113,11 @@ const OpenAIResponsesTool = Schema.Struct({
|
||||
parameters: JsonObject,
|
||||
strict: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
const OpenAIResponsesImageGenerationTool = Schema.Struct({
|
||||
type: Schema.tag("image_generation"),
|
||||
action: Schema.optional(Schema.Literals(["auto", "generate", "edit"])),
|
||||
background: Schema.optional(Schema.Literals(["auto", "opaque", "transparent"])),
|
||||
input_fidelity: Schema.optional(Schema.Literals(["low", "high"])),
|
||||
output_compression: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))),
|
||||
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
|
||||
partial_images: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))),
|
||||
quality: Schema.optional(Schema.Literals(["auto", "low", "medium", "high"])),
|
||||
size: Schema.optional(OpenAIImage.Size),
|
||||
})
|
||||
const OpenAIResponsesTools = Schema.Union([OpenAIResponsesTool, OpenAIResponsesImageGenerationTool])
|
||||
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTools>
|
||||
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTool>
|
||||
|
||||
const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Literals(["auto", "none", "required"]),
|
||||
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
// Fields shared between the HTTP body and the WebSocket `response.create`
|
||||
@@ -142,7 +128,7 @@ const OpenAIResponsesCoreFields = {
|
||||
model: Schema.String,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
instructions: Schema.optional(Schema.String),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tools: optionalArray(OpenAIResponsesTool),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
service_tier: Schema.optional(OpenAIOptions.OpenAIServiceTier),
|
||||
@@ -208,8 +194,6 @@ const OpenAIResponsesStreamItem = Schema.Struct({
|
||||
outputs: Schema.optional(Schema.Unknown),
|
||||
server_label: Schema.optional(Schema.String),
|
||||
output: Schema.optional(Schema.Unknown),
|
||||
result: Schema.optional(Schema.String),
|
||||
output_format: Schema.optional(Schema.Literals(["png", "jpeg", "webp"])),
|
||||
error: Schema.optional(Schema.Unknown),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
})
|
||||
@@ -274,41 +258,21 @@ const invalid = ProviderShared.invalidRequest
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
const native = tool.native?.openai
|
||||
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
|
||||
}
|
||||
|
||||
const nativeImageTool = (tool: ToolDefinition) => {
|
||||
const native = nativeImageToolInput(tool)
|
||||
return Schema.is(OpenAIResponsesImageGenerationTool)(native) ? native : undefined
|
||||
}
|
||||
|
||||
const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDefinition, inputSchema: JsonSchema) {
|
||||
const native = nativeImageToolInput(tool)
|
||||
if (native !== undefined) {
|
||||
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
|
||||
return yield* invalid("OpenAI Responses image generation tool options are invalid")
|
||||
}
|
||||
return {
|
||||
type: "function" as const,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
|
||||
strict: false,
|
||||
}
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema): OpenAIResponsesTool => ({
|
||||
type: "function",
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: ToolSchemaProjection.openAI(inputSchema),
|
||||
// TODO: Read this from OpenAI-specific tool options so direct LLM callers can opt into strict schemas.
|
||||
strict: false,
|
||||
})
|
||||
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tools: ReadonlyArray<ToolDefinition>) =>
|
||||
const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
ProviderShared.matchToolChoice("OpenAI Responses", toolChoice, {
|
||||
auto: () => "auto" as const,
|
||||
none: () => "none" as const,
|
||||
required: () => "required" as const,
|
||||
tool: (name) =>
|
||||
tools.some((tool) => tool.name === name && nativeImageTool(tool) !== undefined)
|
||||
? ({ type: "image_generation" } as const)
|
||||
: { type: "function" as const, name },
|
||||
tool: (name) => ({ type: "function" as const, name }),
|
||||
})
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
|
||||
@@ -456,13 +420,6 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
const itemID = hostedToolItemID(part)
|
||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
if (store === false && part.name === "image_generation" && part.result.type === "content") {
|
||||
const content: ReadonlyArray<ToolContent> = part.result.value
|
||||
input.push({
|
||||
role: "user",
|
||||
content: yield* Effect.forEach(content, lowerToolResultContentItem),
|
||||
})
|
||||
}
|
||||
if (itemID) hostedToolReferences.add(itemID)
|
||||
continue
|
||||
}
|
||||
@@ -528,10 +485,10 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) =>
|
||||
: request.tools.map((tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined,
|
||||
tool_choice: request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
max_output_tokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
@@ -617,29 +574,14 @@ const isReasoningItem = (
|
||||
|
||||
// Round-trip the full item as the structured result so consumers can extract
|
||||
// outputs / sources / status without re-decoding.
|
||||
const hostedToolResult = Effect.fn("OpenAIResponses.hostedToolResult")(function* (item: OpenAIResponsesStreamItem) {
|
||||
const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
|
||||
const isError = typeof item.error !== "undefined" && item.error !== null
|
||||
if (item.type === "image_generation_call" && item.result) {
|
||||
yield* Effect.fromResult(Encoding.decodeBase64(item.result)).pipe(
|
||||
Effect.mapError(() => ProviderShared.eventError(ADAPTER, "OpenAI Responses returned invalid image base64")),
|
||||
)
|
||||
return {
|
||||
type: "content" as const,
|
||||
value: [
|
||||
{
|
||||
type: "file" as const,
|
||||
uri: `data:image/${item.output_format ?? "png"};base64,${item.result}`,
|
||||
mime: `image/${item.output_format ?? "png"}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
||||
})
|
||||
}
|
||||
|
||||
const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function* (
|
||||
const hostedToolEvents = (
|
||||
item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
|
||||
) {
|
||||
): ReadonlyArray<LLMEvent> => {
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||
return [
|
||||
@@ -653,12 +595,12 @@ const hostedToolEvents = Effect.fn("OpenAIResponses.hostedToolEvents")(function*
|
||||
LLMEvent.toolResult({
|
||||
id: item.id,
|
||||
name: tool.name,
|
||||
result: yield* hostedToolResult(item),
|
||||
result: hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
}),
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
|
||||
@@ -893,9 +835,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
hasFunctionCall:
|
||||
resultEvents.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
|
||||
state.hasFunctionCall,
|
||||
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
|
||||
tools: result.tools,
|
||||
},
|
||||
events,
|
||||
@@ -905,7 +845,7 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
if (isHostedToolItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(...(yield* hostedToolEvents(item)))
|
||||
events.push(...hostedToolEvents(item))
|
||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export const reasoningDelta = (
|
||||
providerMetadata?: ProviderMetadata,
|
||||
): State => {
|
||||
const started = reasoningStart(state, events, id, providerMetadata)
|
||||
events.push(LLMEvent.reasoningDelta({ id, text, providerMetadata }))
|
||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
||||
return started
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Schema } from "effect"
|
||||
|
||||
const dimensions = (value: string) => {
|
||||
const match = /^(\d+)x(\d+)$/.exec(value)
|
||||
if (!match) return undefined
|
||||
return { width: Number(match[1]), height: Number(match[2]) }
|
||||
}
|
||||
|
||||
export const Size = Schema.String.check(
|
||||
Schema.makeFilter((value) => {
|
||||
if (value === "auto") return undefined
|
||||
const parsed = dimensions(value)
|
||||
if (!parsed) return "image size must be `auto` or `{width}x{height}`"
|
||||
return parsed.width > 0 && parsed.height > 0 ? undefined : "image dimensions must be positive integers"
|
||||
}),
|
||||
)
|
||||
|
||||
export const OpenAIImage = {
|
||||
Size,
|
||||
} as const
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -53,7 +53,6 @@ const inputStart = (tool: PendingTool) =>
|
||||
LLMEvent.toolInputStart({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
})
|
||||
|
||||
@@ -64,36 +63,19 @@ const inputDelta = (tool: PendingTool, text: string) =>
|
||||
text,
|
||||
})
|
||||
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
|
||||
const raw = inputOverride ?? tool.input
|
||||
return parseToolInput(route, tool.name, raw).pipe(
|
||||
Effect.map((input): ToolCall | ToolInputError =>
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
tool.providerExecuted
|
||||
? Effect.fail(error)
|
||||
: Effect.succeed(
|
||||
LLMEvent.toolInputError({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
raw,
|
||||
}),
|
||||
),
|
||||
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) =>
|
||||
parseToolInput(route, tool.name, inputOverride ?? tool.input).pipe(
|
||||
Effect.map(
|
||||
(input): ToolCall =>
|
||||
LLMEvent.toolCall({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
input,
|
||||
providerExecuted: tool.providerExecuted ? true : undefined,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
|
||||
event.type === "tool-input-error"
|
||||
? [event]
|
||||
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
|
||||
|
||||
/** Store the updated tool and produce the optional public delta event. */
|
||||
const appendTool = <K extends StreamKey>(
|
||||
@@ -140,8 +122,8 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
missingToolMessage: string,
|
||||
): AppendOutcome<K> | LLMError => {
|
||||
const current = tools[key]
|
||||
const id = current?.id ?? delta.id
|
||||
const name = current?.name ?? delta.name
|
||||
const id = delta.id ?? current?.id
|
||||
const name = delta.name ?? current?.name
|
||||
if (!id || !name) return eventError(route, missingToolMessage)
|
||||
|
||||
const tool = {
|
||||
@@ -176,9 +158,8 @@ export const appendExisting = <K extends StreamKey>(
|
||||
|
||||
/**
|
||||
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
|
||||
* from state, and return either a call or a non-executable local input error.
|
||||
* Missing keys are a no-op because some providers emit stop events for
|
||||
* non-tool content blocks.
|
||||
* from state, and return the optional public `tool-call` event. Missing keys are
|
||||
* a no-op because some providers emit stop events for non-tool content blocks.
|
||||
*/
|
||||
export const finish = <K extends StreamKey>(route: string, tools: State<K>, key: K) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -186,7 +167,10 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
|
||||
if (!tool) return { tools }
|
||||
return {
|
||||
tools: withoutTool(tools, key),
|
||||
events: finishEvents(tool, yield* toolCall(route, tool)),
|
||||
events: [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
yield* toolCall(route, tool),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
@@ -201,14 +185,17 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
|
||||
if (!tool) return { tools }
|
||||
return {
|
||||
tools: withoutTool(tools, key),
|
||||
events: finishEvents(tool, yield* toolCall(route, tool, input)),
|
||||
events: [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
yield* toolCall(route, tool, input),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Finalize every pending tool call at once. OpenAI Chat has this shape: it does
|
||||
* not emit per-tool stop events, so all accumulated calls finish independently
|
||||
* when the choice receives a terminal `finish_reason`.
|
||||
* not emit per-tool stop events, so all accumulated calls finish when the choice
|
||||
* receives a terminal `finish_reason`.
|
||||
*/
|
||||
export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -218,7 +205,12 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
|
||||
return {
|
||||
tools: empty<K>(),
|
||||
events: yield* Effect.forEach(pending, (tool) =>
|
||||
toolCall(route, tool).pipe(Effect.map((event) => finishEvents(tool, event))),
|
||||
toolCall(route, tool).pipe(
|
||||
Effect.map((call) => [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
call,
|
||||
]),
|
||||
),
|
||||
).pipe(Effect.map((events) => events.flat())),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,17 +16,13 @@ import {
|
||||
|
||||
const patterns = [
|
||||
/prompt is too long/i,
|
||||
/request_too_large/i,
|
||||
/input is too long for requested model/i,
|
||||
/exceeds the context window/i,
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length(?: of [\d,]+ tokens?|\s*\([\d,]+\))/i,
|
||||
/input token count.*exceeds the maximum/i,
|
||||
/tokens in request more than max tokens allowed/i,
|
||||
/maximum prompt length is \d+/i,
|
||||
/reduce the length of the messages/i,
|
||||
/maximum context length is \d+ tokens/i,
|
||||
/exceeds (?:the )?maximum allowed input length of [\d,]+ tokens?/i,
|
||||
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i,
|
||||
/exceeds the limit of \d+/i,
|
||||
/exceeds the available context size/i,
|
||||
/greater than the context length/i,
|
||||
@@ -38,17 +34,11 @@ const patterns = [
|
||||
/input length.*exceeds.*context length/i,
|
||||
/prompt too long; exceeded (?:max )?context length/i,
|
||||
/too large for model with \d+ maximum context length/i,
|
||||
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i,
|
||||
/model_context_window_exceeded/i,
|
||||
/too many tokens/i,
|
||||
/token limit exceeded/i,
|
||||
]
|
||||
|
||||
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
|
||||
|
||||
export const isContextOverflow = (message: string) =>
|
||||
!exclusions.some((pattern) => pattern.test(message)) &&
|
||||
(patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message))
|
||||
patterns.some((pattern) => pattern.test(message)) || /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message)
|
||||
|
||||
export const isContextOverflowFailure = (failure: unknown) =>
|
||||
failure instanceof LLMError
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
|
||||
import type { Route, RouteDefaultsInput } from "../route/client"
|
||||
import type { ProviderPackage } from "../provider-package"
|
||||
import { HttpOptions, ProviderID, ToolDefinition, mergeHttpOptions, type ModelID } from "../schema"
|
||||
import { ProviderID, type ModelID } from "../schema"
|
||||
import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||
import { OpenAIImages, type OpenAIImageOptions } from "../protocols/openai-images"
|
||||
|
||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||
export type { OpenAIImageOptions } from "../protocols/openai-images"
|
||||
|
||||
export const id = ProviderID.make("openai")
|
||||
|
||||
@@ -22,44 +20,8 @@ export type Config = RouteDefaultsInput &
|
||||
readonly baseURL?: string
|
||||
readonly queryParams?: Record<string, string>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
readonly image?: ImageConfig
|
||||
}
|
||||
|
||||
export interface ImageConfig {
|
||||
readonly providerOptions?: OpenAIImageOptions
|
||||
}
|
||||
|
||||
export interface ImageGenerationOptions {
|
||||
readonly action?: "auto" | "generate" | "edit"
|
||||
readonly background?: "auto" | "opaque" | "transparent"
|
||||
readonly inputFidelity?: "low" | "high"
|
||||
readonly outputCompression?: number
|
||||
readonly outputFormat?: "png" | "jpeg" | "webp"
|
||||
readonly partialImages?: number
|
||||
readonly quality?: "auto" | "low" | "medium" | "high"
|
||||
readonly size?: string
|
||||
}
|
||||
|
||||
export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||
ToolDefinition.make({
|
||||
name: "image_generation",
|
||||
description: "Generate or edit an image using OpenAI's hosted image generation tool.",
|
||||
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
||||
native: {
|
||||
openai: {
|
||||
type: "image_generation",
|
||||
action: options.action,
|
||||
background: options.background,
|
||||
input_fidelity: options.inputFidelity,
|
||||
output_compression: options.outputCompression,
|
||||
output_format: options.outputFormat,
|
||||
partial_images: options.partialImages,
|
||||
quality: options.quality,
|
||||
size: options.size,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
@@ -73,7 +35,7 @@ export interface Settings extends ProviderPackage.Settings {
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||
|
||||
const defaults = (input: Config) => {
|
||||
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, image: _image, ...rest } = input
|
||||
const { apiKey: _, auth: _auth, baseURL: _baseURL, queryParams: _queryParams, ...rest } = input
|
||||
return rest
|
||||
}
|
||||
|
||||
@@ -93,21 +55,6 @@ export const configure = (input: Config = {}) => {
|
||||
const responsesWebSocket = (id: string | ModelID) =>
|
||||
responsesWebSocketRoute.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true })).model({ id })
|
||||
const chat = (id: string | ModelID) => chatRoute.with(withOpenAIOptions(id, modelDefaults)).model({ id })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
OpenAIImages.model({
|
||||
id: modelID,
|
||||
auth: auth(input),
|
||||
baseURL: input.baseURL,
|
||||
headers: input.headers,
|
||||
defaults: {
|
||||
providerOptions:
|
||||
input.image?.providerOptions === undefined ? undefined : { openai: { ...input.image.providerOptions } },
|
||||
http: mergeHttpOptions(
|
||||
input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
input.queryParams === undefined ? undefined : new HttpOptions({ query: input.queryParams }),
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -115,7 +62,6 @@ export const configure = (input: Config = {}) => {
|
||||
responses,
|
||||
responsesWebSocket,
|
||||
chat,
|
||||
image,
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -151,4 +97,3 @@ export const chatModel: ProviderPackage.Definition<Settings>["model"] = (modelID
|
||||
export const responses = provider.responses
|
||||
export const responsesWebSocket = provider.responsesWebSocket
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -41,31 +41,13 @@ export const protocol = Protocol.make({
|
||||
schema: OpenRouterBody,
|
||||
from: (request) =>
|
||||
OpenAIChat.protocol.body.from(request).pipe(
|
||||
Effect.map((body) => {
|
||||
const sourceAssistants = request.messages.filter((message) => message.role === "assistant")
|
||||
let assistantIndex = 0
|
||||
const messages = body.messages.map((message) => {
|
||||
if (message.role !== "assistant") return message
|
||||
const source = sourceAssistants[assistantIndex++]
|
||||
const reasoning = source?.content
|
||||
.filter((part) => part.type === "reasoning")
|
||||
.map((part) => part.text)
|
||||
.join("")
|
||||
const reasoningDetails = Array.isArray(message.reasoning_details) ? message.reasoning_details : undefined
|
||||
return {
|
||||
...message,
|
||||
reasoning_content: undefined,
|
||||
reasoning_text: undefined,
|
||||
reasoning: reasoning && reasoningDetails && reasoningDetails.length > 0 ? reasoning : undefined,
|
||||
reasoning_details: reasoningDetails,
|
||||
}
|
||||
})
|
||||
return {
|
||||
...body,
|
||||
messages,
|
||||
...bodyOptions(request.providerOptions?.openrouter),
|
||||
} as OpenRouterBody
|
||||
}),
|
||||
Effect.map(
|
||||
(body) =>
|
||||
({
|
||||
...body,
|
||||
...bodyOptions(request.providerOptions?.openrouter),
|
||||
}) as OpenRouterBody,
|
||||
),
|
||||
),
|
||||
},
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Redacted } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AuthenticationReason, InvalidRequestReason, LLMError, type HttpOptions } from "../schema"
|
||||
import { AuthenticationReason, InvalidRequestReason, LLMError, type LLMRequest } from "../schema"
|
||||
|
||||
export class MissingCredentialError extends Error {
|
||||
readonly _tag = "MissingCredentialError"
|
||||
@@ -15,7 +15,7 @@ export type AuthError = CredentialError | LLMError
|
||||
type Secret = string | Redacted.Redacted | Config.Config<string | Redacted.Redacted>
|
||||
|
||||
export interface AuthInput {
|
||||
readonly request: { readonly http?: HttpOptions }
|
||||
readonly request: LLMRequest
|
||||
readonly method: "POST" | "GET"
|
||||
readonly url: string
|
||||
readonly body: string
|
||||
|
||||
@@ -129,7 +129,6 @@ export const ToolInputStart = Schema.Struct({
|
||||
type: Schema.tag("tool-input-start"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
providerExecuted: Schema.optional(Schema.Boolean),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputStart" })
|
||||
export type ToolInputStart = Schema.Schema.Type<typeof ToolInputStart>
|
||||
@@ -150,15 +149,6 @@ export const ToolInputEnd = Schema.Struct({
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputEnd" })
|
||||
export type ToolInputEnd = Schema.Schema.Type<typeof ToolInputEnd>
|
||||
|
||||
/** A local tool call whose final input could not be decoded. */
|
||||
export const ToolInputError = Schema.Struct({
|
||||
type: Schema.tag("tool-input-error"),
|
||||
id: ToolCallID,
|
||||
name: Schema.String,
|
||||
raw: Schema.String,
|
||||
}).annotate({ identifier: "LLM.Event.ToolInputError" })
|
||||
export type ToolInputError = Schema.Schema.Type<typeof ToolInputError>
|
||||
|
||||
export const ToolCall = Schema.Struct({
|
||||
type: Schema.tag("tool-call"),
|
||||
id: ToolCallID,
|
||||
@@ -226,7 +216,6 @@ const llmEventTagged = Schema.Union([
|
||||
ToolInputStart,
|
||||
ToolInputDelta,
|
||||
ToolInputEnd,
|
||||
ToolInputError,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
ToolError,
|
||||
@@ -264,8 +253,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
toolInputDelta: (input: WithID<ToolInputDelta, ToolCallID>) =>
|
||||
ToolInputDelta.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputEnd: (input: WithID<ToolInputEnd, ToolCallID>) => ToolInputEnd.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolInputError: (input: WithID<ToolInputError, ToolCallID>) =>
|
||||
ToolInputError.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolCall: (input: WithID<ToolCall, ToolCallID>) => ToolCall.make({ ...input, id: toolCallID(input.id) }),
|
||||
toolResult: (input: WithID<ToolResult, ToolCallID>) =>
|
||||
ToolResult.make({
|
||||
@@ -296,7 +283,6 @@ export const LLMEvent = Object.assign(llmEventTagged, {
|
||||
toolInputStart: llmEventTagged.guards["tool-input-start"],
|
||||
toolInputDelta: llmEventTagged.guards["tool-input-delta"],
|
||||
toolInputEnd: llmEventTagged.guards["tool-input-end"],
|
||||
toolInputError: llmEventTagged.guards["tool-input-error"],
|
||||
toolCall: llmEventTagged.guards["tool-call"],
|
||||
toolResult: llmEventTagged.guards["tool-result"],
|
||||
toolError: llmEventTagged.guards["tool-error"],
|
||||
@@ -562,10 +548,6 @@ const reduceResponseState = (state: ResponseState, event: LLMEvent): ResponseSta
|
||||
return reduceToolInputDelta(next, event)
|
||||
case "tool-input-end":
|
||||
return reduceToolInputEnd(next, event)
|
||||
case "tool-input-error": {
|
||||
const { [event.id]: _finished, ...toolInputs } = next.toolInputs
|
||||
return { ...next, toolInputs }
|
||||
}
|
||||
case "tool-call":
|
||||
return reduceToolCall(next, event)
|
||||
case "tool-result":
|
||||
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "minimax",
|
||||
"protocol": "anthropic-messages",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:anthropic-compatible-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"text",
|
||||
"golden"
|
||||
],
|
||||
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-text",
|
||||
"recordedAt": "2026-07-18T03:42:22.893Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"You are concise.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Reply exactly with: Hello!\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"1a0b363d0882af316faebcec4d4855a8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":53,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"!\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":53,\"output_tokens\":2,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "minimax",
|
||||
"protocol": "anthropic-messages",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:anthropic-compatible-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-call",
|
||||
"golden"
|
||||
],
|
||||
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-call",
|
||||
"recordedAt": "2026-07-18T03:42:23.876Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Call tools exactly as requested.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Call get_weather with city exactly Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6731ecc323233459d1792df9a733dd98\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":404,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_vkxtif4epmvm_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":290,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"provider": "minimax",
|
||||
"protocol": "anthropic-messages",
|
||||
"route": "anthropic-messages",
|
||||
"transport": "http",
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:anthropic-compatible-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"golden"
|
||||
],
|
||||
"name": "anthropic-compatible-messages/minimax-m3-anthropic-compatible-tool-loop",
|
||||
"recordedAt": "2026-07-18T03:42:25.248Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"3807fa12f9ecb9357df511e099da6da0\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":417,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\": \\\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":303,\"output_tokens\":27,\"cache_read_input_tokens\":114,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"system\":[{\"type\":\"text\",\"text\":\"Use the get_weather tool exactly once. After the tool result, reply exactly: Paris is sunny.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_function_yr64rwmre4gr_1\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_function_yr64rwmre4gr_1\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":80,\"temperature\":0}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"92f8a1e86f29946eb2699d40a088fc08\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":41,\"output_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"},\"service_tier\":\"standard\"}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" is sunny.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":41,\"output_tokens\":4,\"cache_read_input_tokens\":430,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
-50
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"tags": [
|
||||
"prefix:openai-compatible-chat",
|
||||
"provider:openrouter",
|
||||
"protocol:openai-chat",
|
||||
"reasoning"
|
||||
],
|
||||
"name": "openrouter-reasoning",
|
||||
"recordedAt": "2026-07-18T11:28:39.267Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://openrouter.ai/api/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"anthropic/claude-sonnet-4.6\",\"messages\":[{\"role\":\"system\",\"content\":\"Think through the arithmetic, then reply with only the final integer.\"},{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":1536,\"temperature\":0,\"reasoning\":{\"max_tokens\":1024}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": ": OPENROUTER PROCESSING\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"173\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"173\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\n: OPENROUTER PROCESSING\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\" × 219\\n\\n173 × 200 = 34,600\\n173 × 19 = 173 × 20 - 173 = 3,460 - 173 = 3,287\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":\"\\n\\n34,600 + 3,287 = 37,887\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"\\n\\n34,600 + 3,287 = 37,887\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning_details\":[{\"type\":\"reasoning.text\",\"signature\":\"EtgCCosBCA8YAipA0W4viH3kgBs43Cl5ewwVBPXTQElvzfbA2TLF4iSbKy9ZZDCSDjjAlF3Bs4ELEnP3vrrTuTioC6OB380lXQdyIDIRY2xhdWRlLXNvbm5ldC00LTY4AEIIdGhpbmtpbmdaJDRjMGYwNDZmLTI1ZmQtNDVmYi1iZmIzLWEwOGE4ZTI0OWNhNxIMMiUlJC3x/5p5PuTwGgwlc8eipZyoM94BHwMiMO45uQx/ymeOjbugi7RDVPZ4jZXSIiEbVi2CD7zPjAK5fFQoVGP1HD55v9CER823JCp6Dg5Xb7Lrk6NUd1XN2KTKrttK7mATE+IBrDTFmor/1cNeg+9gjIbxM/jn/6L5HPmh3/esEVu24Q0IGLZVoE7cTgGgxsrceKMD71Jp2XQgIWD8ltsPfWw3gSc4p+z18UuPN6LuR0mHHENTnClHrAPnOrxbDIl4ZwZgMX8YAQ==\",\"format\":\"anthropic-claude-v1\",\"index\":0}]},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"37887\",\"role\":\"assistant\"},\"finish_reason\":null,\"native_finish_reason\":null}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\",\"reasoning\":null},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}]}\n\ndata: {\"id\":\"gen-1784374117-AXXPsQRoclZeQGx2uHeK\",\"object\":\"chat.completion.chunk\",\"created\":1784374117,\"model\":\"anthropic/claude-sonnet-4.6\",\"provider\":\"Anthropic\",\"service_tier\":\"default\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\",\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"native_finish_reason\":\"end_turn\"}],\"usage\":{\"prompt_tokens\":61,\"completion_tokens\":80,\"total_tokens\":141,\"cost\":0.001383,\"is_byok\":false,\"prompt_tokens_details\":{\"cached_tokens\":0,\"cache_write_tokens\":0,\"audio_tokens\":0,\"video_tokens\":0},\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"cost_details\":{\"upstream_inference_cost\":0.001383,\"upstream_inference_prompt_cost\":0.000183,\"upstream_inference_completions_cost\":0.0012},\"completion_tokens_details\":{\"reasoning_tokens\":29,\"image_tokens\":0,\"audio_tokens\":0}}}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
-55
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,95 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Image, ImageClient } from "../src"
|
||||
import { OpenAI } from "../src/providers"
|
||||
import { it } from "./lib/effect"
|
||||
import { dynamicResponse } from "./lib/http"
|
||||
|
||||
describe("Image", () => {
|
||||
it.effect("generates images through the OpenAI Images API", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: OpenAI.configure({
|
||||
apiKey: "test",
|
||||
baseURL: "https://api.openai.test/v1",
|
||||
queryParams: { "api-version": "v1" },
|
||||
http: { body: { deployment: "test" }, headers: { "x-default": "yes" } },
|
||||
}).image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
count: 2,
|
||||
size: { width: 1024, height: 1024 },
|
||||
providerOptions: {
|
||||
openai: { quality: "high", outputFormat: "webp" },
|
||||
},
|
||||
http: {
|
||||
body: { request_metadata: "value" },
|
||||
headers: { "x-request": "yes" },
|
||||
query: { trace: "1" },
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(2)
|
||||
expect(response.image?.mediaType).toBe("image/webp")
|
||||
expect(response.image?.data).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
expect(response.image?.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
|
||||
expect(response.usage?.totalTokens).toBe(12)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe("https://api.openai.test/v1/images/generations?api-version=v1&trace=1")
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||
expect(request.headers.get("x-default")).toBe("yes")
|
||||
expect(request.headers.get("x-request")).toBe("yes")
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
model: "gpt-image-2",
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
n: 2,
|
||||
size: "1024x1024",
|
||||
quality: "high",
|
||||
output_format: "webp",
|
||||
deployment: "test",
|
||||
request_metadata: "value",
|
||||
})
|
||||
return input.respond(
|
||||
JSON.stringify({
|
||||
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
|
||||
output_format: "webp",
|
||||
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid common and OpenAI image options locally", () =>
|
||||
Image.generate({
|
||||
model: OpenAI.configure({ apiKey: "test", baseURL: "https://api.openai.test/v1" }).image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
count: -1,
|
||||
size: { width: -1, height: 0.5 },
|
||||
providerOptions: { openai: { outputCompression: 101 } },
|
||||
}).pipe(
|
||||
Effect.flip,
|
||||
Effect.tap((error) =>
|
||||
Effect.sync(() => {
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
Layer.provide(dynamicResponse(() => Effect.die("invalid request should not reach the provider"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -3,30 +3,8 @@ import { isContextOverflow } from "../src"
|
||||
import { classifyProviderFailure } from "../src/provider-error"
|
||||
|
||||
describe("provider error classification", () => {
|
||||
test("classifies provider token limit messages as context overflow", () => {
|
||||
const messages = [
|
||||
"tokens in request more than max tokens allowed",
|
||||
'{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
|
||||
"Requested token count exceeds the model's maximum context length of 131072 tokens.",
|
||||
"Input length (265330) exceeds model's maximum context length (262144).",
|
||||
"Input length 131393 exceeds the maximum allowed input length of 131040 tokens.",
|
||||
"The input (516368 tokens) is longer than the model's context length (262144 tokens).",
|
||||
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
|
||||
"Too many tokens",
|
||||
"Token limit exceeded",
|
||||
]
|
||||
|
||||
expect(messages.every(isContextOverflow)).toBe(true)
|
||||
})
|
||||
|
||||
test("does not classify rate limits as context overflow", () => {
|
||||
const messages = [
|
||||
"Throttling error: Too many tokens, please wait before trying again.",
|
||||
"Rate limit exceeded, please retry after 30 seconds.",
|
||||
"Too many requests. Please slow down.",
|
||||
]
|
||||
|
||||
expect(messages.some(isContextOverflow)).toBe(false)
|
||||
test("classifies Z.AI GLM token limit messages as context overflow", () => {
|
||||
expect(isContextOverflow("tokens in request more than max tokens allowed")).toBe(true)
|
||||
})
|
||||
|
||||
test("classifies V1 plain-text rate limit fallbacks", () => {
|
||||
|
||||
@@ -484,30 +484,6 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed server tool input terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "server_tool_use", id: "call_1", name: "web_search" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"partial' },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
)
|
||||
|
||||
const error = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(LLMError)
|
||||
expect(error.message).toContain("Invalid JSON input for anthropic-messages tool call web_search")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails with a typed provider error for stream error frames", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -303,32 +303,6 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed tool input as an unexecuted tool error", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
[
|
||||
"contentBlockStart",
|
||||
{
|
||||
contentBlockIndex: 0,
|
||||
start: { toolUse: { toolUseId: "tool_1", name: "lookup" } },
|
||||
},
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: '{"query":"partial' } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
|
||||
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
|
||||
id: "tool_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes reasoning deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Schema } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMEvent } from "../../src"
|
||||
import { LLM } from "../../src"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -83,59 +83,6 @@ describe("Cloudflare", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves reasoning details for AI Gateway continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = CloudflareAIGateway.configure({
|
||||
accountId: "test-account",
|
||||
gatewayId: "test-gateway",
|
||||
apiKey: "test-token",
|
||||
}).model("anthropic/claude-sonnet-4.6")
|
||||
const details = [
|
||||
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
|
||||
]
|
||||
const merged = [
|
||||
{
|
||||
type: "reasoning.text",
|
||||
text: "Thinking",
|
||||
signature: "signed",
|
||||
format: "anthropic-claude-v1",
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
const response = yield* LLM.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(
|
||||
sseEvents(
|
||||
deltaChunk({ reasoning: "Think", reasoning_details: [details[0]] }),
|
||||
deltaChunk({ reasoning: "ing", reasoning_details: [details[1]] }),
|
||||
deltaChunk({ reasoning_details: [details[2]] }),
|
||||
deltaChunk({ content: "Hello" }),
|
||||
deltaChunk({}, "stop"),
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("Thinking")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(2)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare(LLM.request({ model, messages: [response.message] }))
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "Thinking", reasoning_details: merged },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults AI Gateway id to default when omitted or blank", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as Anthropic from "../../src/providers/anthropic"
|
||||
import * as AnthropicCompatible from "../../src/providers/anthropic-compatible"
|
||||
import { CloudflareAIGateway, CloudflareWorkersAI } from "../../src/providers/cloudflare"
|
||||
import * as Google from "../../src/providers/google"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -18,11 +17,6 @@ const anthropic = Anthropic.configure({
|
||||
})
|
||||
const anthropicHaiku = anthropic.model("claude-haiku-4-5-20251001")
|
||||
const anthropicOpus = anthropic.model("claude-opus-4-7")
|
||||
const minimax = AnthropicCompatible.configure({
|
||||
apiKey: process.env.MINIMAX_API_KEY ?? "fixture",
|
||||
baseURL: "https://api.minimax.io/anthropic/v1",
|
||||
provider: "minimax",
|
||||
}).model("MiniMax-M3")
|
||||
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture" })
|
||||
const gemini = google.model("gemini-2.5-flash")
|
||||
const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
|
||||
@@ -114,15 +108,6 @@ describeRecordedGoldenScenarios([
|
||||
{ id: "image-tool-result", temperature: false, maxTokens: 40 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "MiniMax M3 Anthropic-compatible",
|
||||
prefix: "anthropic-compatible-messages",
|
||||
protocol: "anthropic-messages",
|
||||
model: minimax,
|
||||
requires: ["MINIMAX_API_KEY"],
|
||||
options: { redact: { allowRequestHeaders: ["anthropic-version"] } },
|
||||
scenarios: ["text", "tool-call", "tool-loop"],
|
||||
},
|
||||
{
|
||||
name: "Gemini 2.5 Flash",
|
||||
prefix: "gemini",
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMResponse } from "../../src"
|
||||
import { OpenAIChat } from "../../src/protocols/openai-chat"
|
||||
import * as OpenAICompatible from "../../src/providers/openai-compatible"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
import { expectWeatherToolLoop, goldenWeatherToolLoopRequest, runWeatherToolLoop } from "../recorded-scenarios"
|
||||
|
||||
const cases = [
|
||||
{
|
||||
name: "OpenRouter",
|
||||
model: OpenRouter.configure({
|
||||
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
|
||||
providerOptions: { openrouter: { reasoning: { max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
requires: ["OPENROUTER_API_KEY"],
|
||||
cassette: "openrouter-reasoning",
|
||||
structured: true,
|
||||
},
|
||||
{
|
||||
name: "Vercel AI Gateway",
|
||||
model: OpenAICompatible.configure({
|
||||
provider: "vercel-ai-gateway",
|
||||
baseURL: "https://ai-gateway.vercel.sh/v1",
|
||||
apiKey: process.env.AI_GATEWAY_API_KEY ?? "fixture",
|
||||
http: { body: { reasoning: { enabled: true, max_tokens: 1024 } } },
|
||||
}).model("anthropic/claude-sonnet-4.6"),
|
||||
requires: ["AI_GATEWAY_API_KEY"],
|
||||
cassette: "vercel-ai-gateway-reasoning",
|
||||
structured: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const item of cases) {
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-compatible-chat",
|
||||
provider: item.model.provider,
|
||||
protocol: "openai-chat",
|
||||
requires: item.requires,
|
||||
tags: ["reasoning"],
|
||||
metadata: { model: item.model.id },
|
||||
})
|
||||
|
||||
describe(`${item.name} reasoning recorded`, () => {
|
||||
recorded.effect.with(
|
||||
"streams scalar reasoning",
|
||||
{ cassette: item.cassette },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: item.model,
|
||||
system: "Think through the arithmetic, then reply with only the final integer.",
|
||||
prompt: "What is 173 multiplied by 219?",
|
||||
generation: { maxTokens: 1536, temperature: 0 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
const metadata = response.message.content.find((part) => part.type === "reasoning")?.providerMetadata
|
||||
expect(metadata?.openai?.reasoningField).toBe(item.structured ? "reasoning" : "reasoning_content")
|
||||
expect(Array.isArray(metadata?.openai?.reasoningDetails)).toBe(item.structured)
|
||||
if (!item.structured) return
|
||||
const details = metadata?.openai?.reasoningDetails
|
||||
if (!Array.isArray(details)) return
|
||||
expect(
|
||||
details.some(
|
||||
(detail) =>
|
||||
typeof detail === "object" &&
|
||||
detail !== null &&
|
||||
"signature" in detail &&
|
||||
typeof detail.signature === "string" &&
|
||||
detail.signature.length > 0,
|
||||
),
|
||||
).toBe(true)
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model: item.model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toMatchObject([
|
||||
{ role: "assistant", content: response.text, reasoning: response.reasoning },
|
||||
])
|
||||
const replayDetails =
|
||||
replay.body.messages[0]?.role === "assistant" ? replay.body.messages[0].reasoning_details : undefined
|
||||
expect(Array.isArray(replayDetails)).toBe(true)
|
||||
if (!Array.isArray(replayDetails)) return
|
||||
expect(replayDetails).toEqual(details)
|
||||
expect(replayDetails).toHaveLength(1)
|
||||
expect(replayDetails[0]).toMatchObject({
|
||||
type: "reasoning.text",
|
||||
text: response.reasoning,
|
||||
signature: expect.any(String),
|
||||
})
|
||||
}),
|
||||
30_000,
|
||||
)
|
||||
|
||||
recorded.effect.with(
|
||||
"continues signed reasoning through a tool loop",
|
||||
{ cassette: `${item.cassette}-tool-loop`, tags: ["continuation", "tool", "tool-loop"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const events = yield* runWeatherToolLoop(
|
||||
goldenWeatherToolLoopRequest({
|
||||
id: `${item.cassette}-tool-loop`,
|
||||
model: item.model,
|
||||
maxTokens: 1536,
|
||||
temperature: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expectWeatherToolLoop(events)
|
||||
expect(
|
||||
LLMResponse.text({
|
||||
events: events.slice(events.findIndex(LLMEvent.is.stepFinish) + 1),
|
||||
}).trim(),
|
||||
).toMatch(/^Paris is sunny\.?$/)
|
||||
const details = events
|
||||
.filter(LLMEvent.is.reasoningEnd)
|
||||
.map((event) => event.providerMetadata?.openai?.reasoningDetails)
|
||||
.find(Array.isArray)
|
||||
expect(Array.isArray(details)).toBe(item.structured)
|
||||
if (!item.structured || !Array.isArray(details)) return
|
||||
expect(
|
||||
details.some(
|
||||
(detail) =>
|
||||
typeof detail === "object" &&
|
||||
detail !== null &&
|
||||
"signature" in detail &&
|
||||
typeof detail.signature === "string" &&
|
||||
detail.signature.length > 0,
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -540,401 +540,28 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses and replays OpenAI-compatible reasoning fields", () =>
|
||||
it.effect("parses OpenAI-compatible reasoning content deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const fields = ["reasoning_content", "reasoning", "reasoning_text"] as const
|
||||
for (const field of fields) {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { [field]: "thinking" } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: field },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", [field]: "thinking" }])
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves and replays reasoning details alongside scalar reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.encrypted", data: "opaque", format: "anthropic-claude-v1", index: 1 },
|
||||
]
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } },
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const body = sseEvents(
|
||||
{ choices: [{ delta: { reasoning_content: "thinking" } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
)
|
||||
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning: "thinking",
|
||||
reasoning_details: details,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses reasoning details as display fallback without inventing a scalar replay field", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.summary", summary: "thinking", format: "openai-responses-v1", index: 0 },
|
||||
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
|
||||
]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_details: [details[0]] } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningDetails: details },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: details }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves unknown reasoning details while using scalar display text", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses scalar display text for signature-only reasoning details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", signature: "signed", format: "provider-v2", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: details } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: details },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores scalar reasoning after content starts", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.text", text: "detail", format: "unknown", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_details: details } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: { reasoning: "scalar" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("detail")
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningDetails: details },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves an explicitly empty reasoning details array", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_details: [] } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("")
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningDetails: [] },
|
||||
})
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_details: [] }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("attaches signature-only details that arrive after content", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.text", text: "thinking", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
|
||||
]
|
||||
const merged = [
|
||||
{
|
||||
type: "reasoning.text",
|
||||
text: "thinking",
|
||||
signature: "signed",
|
||||
format: "anthropic-claude-v1",
|
||||
index: 0,
|
||||
},
|
||||
]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning: "thinking", reasoning_details: [details[0]] } }] },
|
||||
{ choices: [{ delta: { content: "Hello" } }] },
|
||||
{ choices: [{ delta: { reasoning_details: [details[1]] } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("thinking")
|
||||
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
|
||||
expect(response.message.content.find((part) => part.type === "reasoning")?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningDelta)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd).at(-1)?.providerMetadata).toEqual({
|
||||
openai: { reasoningField: "reasoning", reasoningDetails: merged },
|
||||
})
|
||||
expect(response.events.findIndex(LLMEvent.is.reasoningEnd)).toBeLessThan(
|
||||
response.events.findIndex(LLMEvent.is.textStart),
|
||||
)
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: "Hello", reasoning: "thinking", reasoning_details: merged },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves metadata-only reasoning when the stream ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_details: details } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "", providerMetadata: { openai: { reasoningDetails: details } } },
|
||||
])
|
||||
expect(response.events.filter(LLMEvent.is.reasoningStart)).toHaveLength(1)
|
||||
expect(response.events.filter(LLMEvent.is.reasoningEnd)).toHaveLength(1)
|
||||
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({ model, messages: [response.message] }),
|
||||
)
|
||||
expect(replay.body.messages).toEqual([{ role: "assistant", content: null, reasoning_details: details }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("flushes details-only display reasoning when the stream ends", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.summary", summary: "summary", format: "openai-responses-v1", index: 0 }]
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ choices: [{ delta: { reasoning_details: details } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: "stop" }] },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("summary")
|
||||
expect(response.message.content).toEqual([
|
||||
{ type: "reasoning", text: "summary", providerMetadata: { openai: { reasoningDetails: details } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays details from multiple reasoning parts in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = { type: "reasoning.text", text: "first", signature: "signed-0", index: 0 }
|
||||
const second = { type: "reasoning.text", text: "second", signature: "signed-1", index: 1 }
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "first",
|
||||
providerMetadata: { openai: { reasoningDetails: [first] } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "second",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: [second] } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "firstsecond", reasoning_details: [first, second] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains scalar replay for mixed structured reasoning parts", () =>
|
||||
Effect.gen(function* () {
|
||||
const detail = { type: "reasoning.encrypted", data: "opaque", index: 0 }
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "A",
|
||||
providerMetadata: { openai: { reasoningDetails: [detail] } },
|
||||
},
|
||||
{ type: "reasoning", text: "B" },
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning_content: "AB", reasoning_details: [detail] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays native scalar reasoning alongside native details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [{ type: "reasoning.encrypted", data: "opaque", index: 0 }]
|
||||
const replay = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.make({
|
||||
role: "assistant",
|
||||
content: [{ type: "reasoning", text: "thinking" }],
|
||||
native: { openaiCompatible: { reasoning_content: "thinking", reasoning_details: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(replay.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning_content: "thinking", reasoning_details: details },
|
||||
expect(response.text).toBe("Hello")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "reasoning-0" },
|
||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||
{ type: "reasoning-end", id: "reasoning-0" },
|
||||
{ type: "text-start", id: "text-0" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
@@ -975,67 +602,6 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores empty identity fields on later tool call deltas", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }],
|
||||
}),
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }],
|
||||
}),
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("buffers tool call deltas until the function name arrives", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }],
|
||||
}),
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }],
|
||||
}),
|
||||
deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }),
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when a buffered tool call never receives a function name", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
deltaChunk({
|
||||
tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }],
|
||||
}),
|
||||
deltaChunk({}, "tool_calls"),
|
||||
)
|
||||
const error = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)), Effect.flip)
|
||||
|
||||
expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails a streamed tool call when the provider ends without a finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Image } from "../../src"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const model = OpenAI.configure({
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||
image: {
|
||||
providerOptions: {
|
||||
quality: "low",
|
||||
outputFormat: "jpeg",
|
||||
outputCompression: 10,
|
||||
},
|
||||
},
|
||||
}).image("gpt-image-1-mini")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-images",
|
||||
provider: "openai",
|
||||
protocol: "openai-images",
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("OpenAI Images recorded", () => {
|
||||
recorded.effect("generates an image", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "A simple flat black circle centered on a plain white background.",
|
||||
size: { width: 1024, height: 1024 },
|
||||
})
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image?.mediaType).toBe("image/jpeg")
|
||||
expect(response.image?.data).toBeInstanceOf(Uint8Array)
|
||||
expect(response.image?.data.length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,66 +0,0 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src"
|
||||
import { OpenAI } from "../../src/providers"
|
||||
import { recordedTests } from "../recorded-test"
|
||||
|
||||
const openai = OpenAI.configure({
|
||||
apiKey: process.env.OPENAI_API_KEY ?? "fixture",
|
||||
})
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "openai-responses-images",
|
||||
provider: "openai",
|
||||
protocol: "openai-responses",
|
||||
requires: ["OPENAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("OpenAI Responses image generation recorded", () => {
|
||||
recorded.effect("generates and edits an image with the hosted tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const initial = Message.user("Generate a simple flat black triangle centered on a plain white background.")
|
||||
const tools = [
|
||||
OpenAI.imageGeneration({
|
||||
action: "auto",
|
||||
quality: "low",
|
||||
size: "1024x1024",
|
||||
outputFormat: "jpeg",
|
||||
outputCompression: 10,
|
||||
partialImages: 0,
|
||||
}),
|
||||
]
|
||||
const response = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: openai.responses("gpt-5-mini"),
|
||||
messages: [initial],
|
||||
tools,
|
||||
toolChoice: "image_generation",
|
||||
}),
|
||||
)
|
||||
|
||||
const result = response.events.find(LLMEvent.is.toolResult)
|
||||
expect(result).toBeDefined()
|
||||
expect(result?.providerExecuted).toBe(true)
|
||||
expect(result?.result.type).toBe("content")
|
||||
if (result?.result.type !== "content") return
|
||||
expect(result.result.value).toHaveLength(1)
|
||||
expect(result.result.value[0]?.type).toBe("file")
|
||||
if (result.result.value[0]?.type !== "file") return
|
||||
expect(result.result.value[0].mime).toBe("image/jpeg")
|
||||
expect(result.result.value[0].uri.startsWith("data:image/jpeg;base64,")).toBe(true)
|
||||
|
||||
const edited = yield* LLM.generate(
|
||||
LLM.request({
|
||||
model: openai.responses("gpt-5-mini"),
|
||||
messages: [initial, response.message, Message.user("Now make the triangle blue.")],
|
||||
tools,
|
||||
toolChoice: "image_generation",
|
||||
}),
|
||||
)
|
||||
const editedResult = edited.events.find(LLMEvent.is.toolResult)
|
||||
expect(editedResult?.result.type).toBe("content")
|
||||
if (editedResult?.result.type !== "content") return
|
||||
expect(editedResult.result.value[0]?.type).toBe("file")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart, Usage } from "../../src"
|
||||
import { LLM, LLMError, Message, Model, ToolCallPart, Usage } from "../../src"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
|
||||
import * as Azure from "../../src/providers/azure"
|
||||
import * as OpenAI from "../../src/providers/openai"
|
||||
@@ -58,39 +58,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers the hosted OpenAI image generation tool", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Show me a rooftop garden.",
|
||||
tools: [OpenAI.imageGeneration({ action: "generate", quality: "high", size: "1024x1024" })],
|
||||
toolChoice: "image_generation",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{ type: "image_generation", action: "generate", quality: "high", size: "1024x1024" },
|
||||
])
|
||||
expect(prepared.body.tool_choice).toEqual({ type: "image_generation" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid hosted image generation options locally", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Show me a rooftop garden.",
|
||||
tools: [OpenAI.imageGeneration({ outputCompression: -1, partialImages: 4, size: "bogus" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("image generation tool options are invalid")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers semantic service tier options", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = LLM.updateRequest(request, { providerOptions: { openai: { serviceTier: "priority" } } })
|
||||
@@ -1136,48 +1103,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stateless hosted image generation with the generated image", () =>
|
||||
Effect.gen(function* () {
|
||||
const imageTool = OpenAI.imageGeneration({ action: "edit" })
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.user("Generate a black triangle."),
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "ig_1",
|
||||
name: "image_generation",
|
||||
input: {},
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ig_1" } },
|
||||
}),
|
||||
ToolResultPart.make({
|
||||
id: "ig_1",
|
||||
name: "image_generation",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
|
||||
},
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ig_1" } },
|
||||
}),
|
||||
]),
|
||||
Message.user("Make it blue."),
|
||||
],
|
||||
tools: [imageTool],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(false)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Generate a black triangle." }] },
|
||||
{ role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Make it blue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
@@ -1334,69 +1259,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"streamed"}' },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"partial',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
|
||||
type: "tool-input-error",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles malformed function arguments when output_item.added is absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"partial',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
})
|
||||
expect(response.finishReason).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes web_search_call as provider-executed tool-call + tool-result", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
@@ -1436,59 +1298,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes image generation output as image content", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
type: "image_generation_call",
|
||||
id: "ig_1",
|
||||
status: "completed",
|
||||
result: "AQID",
|
||||
}
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.done", item },
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolResult)).toMatchObject({
|
||||
id: "ig_1",
|
||||
name: "image_generation",
|
||||
providerExecuted: true,
|
||||
result: {
|
||||
type: "content",
|
||||
value: [{ type: "file", uri: "data:image/png;base64,AQID", mime: "image/png" }],
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed image generation base64", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "image_generation_call", id: "ig_bad", status: "completed", result: "%%%" },
|
||||
},
|
||||
{ type: "response.completed", response: {} },
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toContain("invalid image base64")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("decodes code_interpreter_call as provider-executed events with code input", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, Message } from "../../src"
|
||||
import { LLM } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import * as OpenRouter from "../../src/providers/openrouter"
|
||||
import { it } from "../lib/effect"
|
||||
@@ -53,102 +53,4 @@ describe("OpenRouter", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves manually supplied reasoning details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.text", text: "Think", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.text", text: "ing", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.text", signature: "signed", format: "anthropic-claude-v1", index: 0 },
|
||||
{ type: "reasoning.encrypted", data: "opaque", format: "openai-responses-v1", index: 1 },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
reasoning: "Thinking",
|
||||
reasoning_details: details,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves opaque and duplicate continuation details", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.future", format: "provider-v2", state: { opaque: true } },
|
||||
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||
{ type: "reasoning.encrypted", id: "state", data: "opaque" },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "Thinking",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "Thinking", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not merge distinct adjacent reasoning text blocks", () =>
|
||||
Effect.gen(function* () {
|
||||
const details = [
|
||||
{ type: "reasoning.text", id: "first", index: 0, text: "A", opaque: "first" },
|
||||
{ type: "reasoning.text", id: "second", index: 1, text: "B", opaque: "second" },
|
||||
]
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [
|
||||
Message.assistant({
|
||||
type: "reasoning",
|
||||
text: "AB",
|
||||
providerMetadata: { openai: { reasoningField: "reasoning", reasoningDetails: details } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: null, reasoning: "AB", reasoning_details: details },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits scalar reasoning without continuation details", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenRouter.OpenRouterBody>(
|
||||
LLM.request({
|
||||
model: OpenRouter.configure({ apiKey: "test-key" }).model("anthropic/claude-sonnet-4.6"),
|
||||
messages: [Message.assistant({ type: "reasoning", text: "Thinking" })],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -120,8 +120,29 @@ export const runWeatherToolLoop = (request: LLMRequest) =>
|
||||
throw new Error("Weather tool loop exceeded 10 steps")
|
||||
})
|
||||
|
||||
const assistantContent = (events: ReadonlyArray<LLMEvent>) =>
|
||||
events.reduce(LLMResponse.reduce, LLMResponse.empty()).message.content
|
||||
const assistantContent = (events: ReadonlyArray<LLMEvent>) => {
|
||||
const content: ContentPart[] = []
|
||||
for (const event of events) {
|
||||
if (event.type === "text-delta" || event.type === "reasoning-delta") {
|
||||
const type = event.type === "text-delta" ? "text" : "reasoning"
|
||||
const last = content.at(-1)
|
||||
if (last?.type === type) {
|
||||
content[content.length - 1] = { ...last, text: `${last.text}${event.text}` }
|
||||
} else {
|
||||
content.push({ type, text: event.text })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (event.type === "text-end" || event.type === "reasoning-end") {
|
||||
const type = event.type === "text-end" ? "text" : "reasoning"
|
||||
const last = content.at(-1)
|
||||
if (last?.type === type) content[content.length - 1] = { ...last, providerMetadata: event.providerMetadata }
|
||||
continue
|
||||
}
|
||||
if (event.type === "tool-call") content.push(event)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
export const expectFinish = (
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
|
||||
@@ -3,8 +3,6 @@ import { Layer } from "effect"
|
||||
import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route"
|
||||
import { ImageClient } from "../src/image-client"
|
||||
import type { Service as ImageClientService } from "../src/image-client"
|
||||
import type { Service as LLMClientService } from "../src/route/client"
|
||||
import type { Service as RequestExecutorService } from "../src/route/executor"
|
||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket"
|
||||
@@ -17,7 +15,7 @@ import {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||
|
||||
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
|
||||
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
||||
|
||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||
readonly options?: HttpRecorder.RecorderOptions
|
||||
@@ -83,10 +81,6 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
),
|
||||
)
|
||||
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
|
||||
return Layer.mergeAll(
|
||||
deps,
|
||||
LLMClient.layer.pipe(Layer.provide(deps)),
|
||||
ImageClient.layer.pipe(Layer.provide(deps)),
|
||||
)
|
||||
return Layer.mergeAll(deps, LLMClient.layer.pipe(Layer.provide(deps)))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -95,19 +95,4 @@ describe("LLMResponse reducer", () => {
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("clears malformed tool input without appending an executable call", () => {
|
||||
const state = reduce([
|
||||
LLMEvent.toolInputStart({ id: "call_1", name: "lookup" }),
|
||||
LLMEvent.toolInputDelta({ id: "call_1", name: "lookup", text: '{"query":"partial' }),
|
||||
LLMEvent.toolInputError({
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
}),
|
||||
])
|
||||
|
||||
expect(state.toolInputs).toEqual({})
|
||||
expect(state.message.content).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -36,33 +36,6 @@ describe("ToolStream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps accumulated identity when later deltas contain empty strings", () =>
|
||||
Effect.gen(function* () {
|
||||
const first = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
ToolStream.empty<number>(),
|
||||
0,
|
||||
{ id: "call_1", name: "lookup", text: '{"query"' },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(first)) return yield* first
|
||||
const second = ToolStream.appendOrStart(
|
||||
ADAPTER,
|
||||
first.tools,
|
||||
0,
|
||||
{ id: "", name: "", text: ':"weather"}' },
|
||||
"missing tool",
|
||||
)
|
||||
if (ToolStream.isError(second)) return yield* second
|
||||
const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)
|
||||
|
||||
expect(finished.events).toEqual([
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails appendExisting when the provider skipped the tool start", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty<number>(), 0, "{}", "missing tool")
|
||||
@@ -91,73 +64,6 @@ describe("ToolStream", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes malformed local input as a non-executable tool error", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: '{"query":"partial',
|
||||
})
|
||||
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
|
||||
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
events: [
|
||||
{
|
||||
type: "tool-input-error",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves valid siblings when one parallel input is malformed", () =>
|
||||
Effect.gen(function* () {
|
||||
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||
id: "call_valid",
|
||||
name: "lookup",
|
||||
input: '{"query":"weather"}',
|
||||
})
|
||||
const tools = ToolStream.start(valid, 1, {
|
||||
id: "call_invalid",
|
||||
name: "lookup",
|
||||
input: '{"query":"partial',
|
||||
})
|
||||
const finished = yield* ToolStream.finishAll(ADAPTER, tools)
|
||||
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
events: [
|
||||
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
|
||||
{
|
||||
type: "tool-input-error",
|
||||
id: "call_invalid",
|
||||
name: "lookup",
|
||||
raw: '{"query":"partial',
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps malformed provider-executed input terminal", () =>
|
||||
Effect.gen(function* () {
|
||||
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
|
||||
id: "call_1",
|
||||
name: "web_search",
|
||||
input: '{"query":"partial',
|
||||
providerExecuted: true,
|
||||
})
|
||||
const result = yield* Effect.exit(ToolStream.finish(ADAPTER, tools, "item_1"))
|
||||
|
||||
expect(result._tag).toBe("Failure")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves providerExecuted and clears all tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const first: ToolStream.State<number> = ToolStream.start(ToolStream.empty<number>(), 0, {
|
||||
|
||||
@@ -1615,7 +1615,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<div class="flex flex-col gap-3">
|
||||
<DockShellForm
|
||||
data-component={newSession() ? "session-new-composer" : "session-composer"}
|
||||
data-background-surface="prompt"
|
||||
onSubmit={handleSubmit}
|
||||
classList={{
|
||||
"group/prompt-input min-h-[96px] w-full rounded-xl bg-v2-background-bg-base shadow-[var(--v2-elevation-raised)]": true,
|
||||
|
||||
@@ -446,7 +446,6 @@ export function PromptProjectAddButton(props: { controller: PromptProjectControl
|
||||
return (
|
||||
<button
|
||||
data-action="prompt-project"
|
||||
data-background-surface="project-selector"
|
||||
type="button"
|
||||
class="flex h-7 min-w-0 max-w-[160px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
onClick={() => props.controller.add()}
|
||||
@@ -465,7 +464,6 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
|
||||
<button
|
||||
{...rest}
|
||||
data-action="prompt-project"
|
||||
data-background-surface="project-selector"
|
||||
type="button"
|
||||
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
|
||||
classList={{
|
||||
|
||||
@@ -4,14 +4,10 @@ import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
|
||||
export function NewSessionDesignView(props: { children: JSX.Element }) {
|
||||
return (
|
||||
<div
|
||||
data-component="session-new-design"
|
||||
data-background-surface="shell"
|
||||
class="relative size-full overflow-hidden bg-v2-background-bg-deep "
|
||||
>
|
||||
<div data-component="session-new-design" class="relative size-full overflow-hidden bg-v2-background-bg-deep ">
|
||||
<div class="absolute inset-x-0 top-[25.375%] flex justify-center px-6">
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse [&>g>g>g]:!opacity-[0.45]" />
|
||||
<WordmarkV2 class="h-auto w-full text-v2-background-bg-inverse" />
|
||||
<div class="mt-8">{props.children}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export function useSettingsBackgroundImage() {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ busy: false })
|
||||
|
||||
const run = async (action: (() => Promise<unknown>) | undefined) => {
|
||||
if (!action || state.busy) return
|
||||
setState("busy", true)
|
||||
try {
|
||||
await action()
|
||||
} catch (error) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
} finally {
|
||||
setState("busy", false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
available: !!platform.selectBackgroundImage,
|
||||
active: () => platform.backgroundImage?.() ?? false,
|
||||
get busy() {
|
||||
return state.busy
|
||||
},
|
||||
select: () => run(platform.selectBackgroundImage),
|
||||
clear: () => run(platform.clearBackgroundImage),
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import { decode64 } from "@/utils/base64"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
import { useSettingsBackgroundImage } from "./settings-background-image"
|
||||
|
||||
let demoSoundState = {
|
||||
cleanup: undefined as (() => void) | undefined,
|
||||
@@ -88,7 +87,6 @@ export const SettingsGeneral: Component = () => {
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const backgroundImage = useSettingsBackgroundImage()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const settings = useSettings()
|
||||
@@ -501,24 +499,6 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={backgroundImage.available}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.backgroundImage.title")}
|
||||
description={language.t("settings.general.row.backgroundImage.description")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button size="small" variant="secondary" disabled={backgroundImage.busy} onClick={backgroundImage.select}>
|
||||
{language.t("settings.general.row.backgroundImage.choose")}
|
||||
</Button>
|
||||
<Show when={backgroundImage.active()}>
|
||||
<Button size="small" variant="ghost" disabled={backgroundImage.busy} onClick={backgroundImage.clear}>
|
||||
{language.t("settings.general.row.backgroundImage.remove")}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</Show>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
|
||||
@@ -29,7 +29,6 @@ import { Link } from "../link"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import { SettingsRowV2 } from "./parts/row"
|
||||
import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition"
|
||||
import { useSettingsBackgroundImage } from "../settings-background-image"
|
||||
import "./settings-v2.css"
|
||||
|
||||
let demoSoundState = {
|
||||
@@ -89,7 +88,6 @@ export const SettingsGeneralV2: Component<{
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const platform = usePlatform()
|
||||
const backgroundImage = useSettingsBackgroundImage()
|
||||
const dialog = useDialog()
|
||||
const settings = useSettings()
|
||||
const serverSync = useServerSync()
|
||||
@@ -462,29 +460,6 @@ export const SettingsGeneralV2: Component<{
|
||||
/>
|
||||
</SettingsRowV2>
|
||||
|
||||
<Show when={backgroundImage.available}>
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.backgroundImage.title")}
|
||||
description={language.t("settings.general.row.backgroundImage.description")}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
disabled={backgroundImage.busy}
|
||||
onClick={backgroundImage.select}
|
||||
>
|
||||
{language.t("settings.general.row.backgroundImage.choose")}
|
||||
</ButtonV2>
|
||||
<Show when={backgroundImage.active()}>
|
||||
<ButtonV2 size="normal" variant="ghost" disabled={backgroundImage.busy} onClick={backgroundImage.clear}>
|
||||
{language.t("settings.general.row.backgroundImage.remove")}
|
||||
</ButtonV2>
|
||||
</Show>
|
||||
</div>
|
||||
</SettingsRowV2>
|
||||
</Show>
|
||||
|
||||
<SettingsRowV2
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
|
||||
@@ -227,7 +227,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
|
||||
return (
|
||||
<header
|
||||
data-background-surface="shell"
|
||||
data-slot={useV2Titlebar() ? "titlebar-v2" : undefined}
|
||||
classList={{
|
||||
"shrink-0 relative flex flex-row": true,
|
||||
|
||||
@@ -112,18 +112,6 @@ type PlatformBase = {
|
||||
/** Read image from clipboard (desktop only) */
|
||||
readClipboardImage?(): Promise<File | null>
|
||||
|
||||
/** Load and apply the saved app background image. */
|
||||
loadBackgroundImage?(): Promise<boolean>
|
||||
|
||||
/** Whether the app currently has a background image. */
|
||||
backgroundImage?: Accessor<boolean>
|
||||
|
||||
/** Select and apply an app background image. */
|
||||
selectBackgroundImage?(): Promise<boolean>
|
||||
|
||||
/** Clear the saved app background image. */
|
||||
clearBackgroundImage?(): Promise<void>
|
||||
|
||||
/** Export collected diagnostic logs (desktop only) */
|
||||
exportDebugLogs?(): Promise<string>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @refresh reload
|
||||
|
||||
import * as Sentry from "@sentry/solid"
|
||||
import { createSignal } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "@/app"
|
||||
import { type Platform, PlatformProvider } from "@/context/platform"
|
||||
@@ -9,12 +8,6 @@ import { dict as en } from "@/i18n/en"
|
||||
import { dict as zh } from "@/i18n/zh"
|
||||
import { handleNotificationClick } from "@/utils/notification-click"
|
||||
import { authFromToken } from "@/utils/server"
|
||||
import {
|
||||
clearWebBackgroundImage,
|
||||
loadWebBackgroundImage,
|
||||
saveWebBackgroundImage,
|
||||
selectWebBackgroundImage,
|
||||
} from "@/utils/web-background-image"
|
||||
import pkg from "../package.json"
|
||||
import { ServerConnection } from "./context/server"
|
||||
|
||||
@@ -126,21 +119,6 @@ const clearAuthToken = () => {
|
||||
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
|
||||
}
|
||||
|
||||
const [backgroundImage, setBackgroundImage] = createSignal(false)
|
||||
let backgroundImageUrl: string | undefined
|
||||
const applyBackgroundImage = (image: Blob | null) => {
|
||||
if (backgroundImageUrl) URL.revokeObjectURL(backgroundImageUrl)
|
||||
backgroundImageUrl = image ? URL.createObjectURL(image) : undefined
|
||||
setBackgroundImage(!!backgroundImageUrl)
|
||||
document.documentElement.toggleAttribute("data-background-image", !!backgroundImageUrl)
|
||||
if (backgroundImageUrl) {
|
||||
document.documentElement.style.setProperty("--app-background-image", `url("${backgroundImageUrl}")`)
|
||||
return true
|
||||
}
|
||||
document.documentElement.style.removeProperty("--app-background-image")
|
||||
return false
|
||||
}
|
||||
|
||||
const platform: Platform = {
|
||||
platform: "web",
|
||||
version: pkg.version,
|
||||
@@ -154,23 +132,8 @@ const platform: Platform = {
|
||||
return stored ? ServerConnection.Key.make(stored) : null
|
||||
},
|
||||
setDefaultServer: writeDefaultServerUrl,
|
||||
backgroundImage,
|
||||
async loadBackgroundImage() {
|
||||
return applyBackgroundImage(await loadWebBackgroundImage())
|
||||
},
|
||||
async selectBackgroundImage() {
|
||||
const file = await selectWebBackgroundImage()
|
||||
if (!file) return backgroundImage()
|
||||
return applyBackgroundImage(await saveWebBackgroundImage(file))
|
||||
},
|
||||
async clearBackgroundImage() {
|
||||
await clearWebBackgroundImage()
|
||||
applyBackgroundImage(null)
|
||||
},
|
||||
}
|
||||
|
||||
void platform.loadBackgroundImage?.()
|
||||
|
||||
if (import.meta.env.VITE_SENTRY_DSN) {
|
||||
Sentry.init({
|
||||
dsn: import.meta.env.VITE_SENTRY_DSN,
|
||||
|
||||
@@ -704,10 +704,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.terminalFont.title": "خط الطرفية",
|
||||
"settings.general.row.terminalFont.description": "خصّص الخط المستخدم في الطرفية",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "خط الواجهة",
|
||||
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
|
||||
"settings.general.row.followup.title": "سلوك المتابعة",
|
||||
|
||||
@@ -713,10 +713,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código",
|
||||
"settings.general.row.terminalFont.title": "Fonte do terminal",
|
||||
"settings.general.row.terminalFont.description": "Personalize a fonte usada no terminal",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "Fonte da interface",
|
||||
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
|
||||
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
||||
|
||||
@@ -778,10 +778,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda",
|
||||
"settings.general.row.terminalFont.title": "Font terminala",
|
||||
"settings.general.row.terminalFont.description": "Prilagodite font koji se koristi u terminalu",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "UI font",
|
||||
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
||||
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
||||
|
||||
@@ -773,10 +773,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke",
|
||||
"settings.general.row.terminalFont.title": "Terminalskrifttype",
|
||||
"settings.general.row.terminalFont.description": "Tilpas den skrifttype, der bruges i terminalen",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "UI-skrifttype",
|
||||
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
|
||||
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
||||
|
||||
@@ -724,10 +724,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Die in Codeblöcken verwendete Schriftart anpassen",
|
||||
"settings.general.row.terminalFont.title": "Terminalschriftart",
|
||||
"settings.general.row.terminalFont.description": "Passe die im Terminal verwendete Schriftart an",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "UI-Schriftart",
|
||||
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
|
||||
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
|
||||
|
||||
@@ -862,10 +862,6 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
|
||||
"settings.general.row.theme.title": "Theme",
|
||||
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.font.title": "Code Font",
|
||||
"settings.general.row.font.description": "Customise the font used in code blocks",
|
||||
"settings.general.row.terminalFont.title": "Terminal Font",
|
||||
|
||||
@@ -781,10 +781,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código",
|
||||
"settings.general.row.terminalFont.title": "Fuente del terminal",
|
||||
"settings.general.row.terminalFont.description": "Personaliza la fuente utilizada en el terminal",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "Fuente de la interfaz",
|
||||
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
|
||||
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
||||
|
||||
@@ -720,10 +720,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code",
|
||||
"settings.general.row.terminalFont.title": "Police du terminal",
|
||||
"settings.general.row.terminalFont.description": "Personnalisez la police utilisée dans le terminal",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "Police de l'interface",
|
||||
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
|
||||
"settings.general.row.followup.title": "Comportement de suivi",
|
||||
|
||||
@@ -709,10 +709,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "コードブロックで使用するフォントをカスタマイズします",
|
||||
"settings.general.row.terminalFont.title": "ターミナルのフォント",
|
||||
"settings.general.row.terminalFont.description": "ターミナルで使用するフォントをカスタマイズ",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "UIフォント",
|
||||
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
|
||||
"settings.general.row.followup.title": "フォローアップの動作",
|
||||
|
||||
@@ -580,10 +580,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "코드 블록에 사용되는 글꼴을 사용자 지정",
|
||||
"settings.general.row.terminalFont.title": "터미널 글꼴",
|
||||
"settings.general.row.terminalFont.description": "터미널에서 사용할 글꼴을 설정합니다",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "UI 글꼴",
|
||||
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
|
||||
"settings.general.row.followup.title": "후속 조치 동작",
|
||||
|
||||
@@ -654,10 +654,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker",
|
||||
"settings.general.row.terminalFont.title": "Terminalskrift",
|
||||
"settings.general.row.terminalFont.description": "Tilpass skrifttypen som brukes i terminalen",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "UI-skrift",
|
||||
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
|
||||
"settings.general.row.followup.title": "Oppfølgingsadferd",
|
||||
|
||||
@@ -714,10 +714,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu",
|
||||
"settings.general.row.terminalFont.title": "Czcionka terminala",
|
||||
"settings.general.row.terminalFont.description": "Dostosuj czcionkę używaną w terminalu",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "Czcionka interfejsu",
|
||||
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
|
||||
"settings.general.row.followup.title": "Zachowanie kontynuacji",
|
||||
|
||||
@@ -778,10 +778,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода",
|
||||
"settings.general.row.terminalFont.title": "Шрифт терминала",
|
||||
"settings.general.row.terminalFont.description": "Настройте шрифт, используемый в терминале",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "Шрифт интерфейса",
|
||||
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
|
||||
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
|
||||
|
||||
@@ -771,10 +771,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ด",
|
||||
"settings.general.row.terminalFont.title": "ฟอนต์เทอร์มินัล",
|
||||
"settings.general.row.terminalFont.description": "ปรับแต่งฟอนต์ที่ใช้ในเทอร์มินัล",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "ฟอนต์ UI",
|
||||
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
|
||||
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
|
||||
|
||||
@@ -784,10 +784,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Kod bloklarında kullanılan yazı tipini özelleştirin",
|
||||
"settings.general.row.terminalFont.title": "Terminal yazı tipi",
|
||||
"settings.general.row.terminalFont.description": "Terminalde kullanılan yazı tipini özelleştirin",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
|
||||
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
|
||||
"settings.general.row.followup.title": "Takip davranışı",
|
||||
|
||||
@@ -870,10 +870,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "Налаштуйте шрифт, який використовується в блоках коду",
|
||||
"settings.general.row.terminalFont.title": "Шрифт термінала",
|
||||
"settings.general.row.terminalFont.description": "Налаштуйте шрифт, який використовується в терміналі",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "Шрифт інтерфейсу",
|
||||
"settings.general.row.uiFont.description": "Налаштуйте шрифт, який використовується в інтерфейсі",
|
||||
"settings.general.row.followup.title": "Поведінка продовження",
|
||||
|
||||
@@ -768,10 +768,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "自定义代码块使用的字体",
|
||||
"settings.general.row.terminalFont.title": "终端字体",
|
||||
"settings.general.row.terminalFont.description": "自定义终端使用的字体",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "界面字体",
|
||||
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
|
||||
"settings.general.row.followup.title": "跟进消息行为",
|
||||
|
||||
@@ -763,10 +763,6 @@ export const dict = {
|
||||
"settings.general.row.font.description": "自訂程式碼區塊使用的字型",
|
||||
"settings.general.row.terminalFont.title": "終端機字型",
|
||||
"settings.general.row.terminalFont.description": "自訂終端機使用的字型",
|
||||
"settings.general.row.backgroundImage.title": "Background image",
|
||||
"settings.general.row.backgroundImage.description": "Choose an image for the app background.",
|
||||
"settings.general.row.backgroundImage.choose": "Choose image",
|
||||
"settings.general.row.backgroundImage.remove": "Remove",
|
||||
"settings.general.row.uiFont.title": "介面字型",
|
||||
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
|
||||
"settings.general.row.followup.title": "後續追問行為",
|
||||
|
||||
@@ -3,62 +3,6 @@
|
||||
@import "@opencode-ai/ui/v2/styles/tailwind.css";
|
||||
@import "tw-animate-css";
|
||||
|
||||
html[data-background-image] body {
|
||||
background-image: linear-gradient(rgb(0 0 0 / 30%), rgb(0 0 0 / 30%)), var(--app-background-image);
|
||||
background-color: transparent !important;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
html[data-background-image] #root {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="shell"] {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="content"] {
|
||||
background-color: color-mix(in srgb, var(--background-base) 68%, transparent) !important;
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="panel"] {
|
||||
background-color: color-mix(in srgb, var(--v2-background-bg-base) 56%, transparent) !important;
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="prompt"] {
|
||||
background-color: color-mix(in srgb, var(--v2-background-bg-base) 68%, transparent) !important;
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="project-selector"] {
|
||||
background-color: color-mix(in srgb, var(--v2-background-bg-base) 68%, transparent) !important;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--v2-border-border-base) 70%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="prompt"] [data-background-surface="project-selector"] {
|
||||
background-color: transparent !important;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="workspace-bar"] {
|
||||
padding-inline: 6px;
|
||||
padding-block: 2px;
|
||||
border-radius: 6px;
|
||||
background-color: color-mix(in srgb, var(--v2-background-bg-base) 68%, transparent) !important;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--v2-border-border-base) 70%, transparent);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
html[data-background-image] [data-background-surface="workspace-bar"] [data-background-surface="project-selector"] {
|
||||
background-color: transparent !important;
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "JetBrainsMono Nerd Font Mono";
|
||||
src: url("/assets/JetBrainsMonoNerdFontMono-Regular.woff2") format("woff2");
|
||||
|
||||
@@ -598,10 +598,7 @@ export function NewHome() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-background-surface="panel"
|
||||
class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1"
|
||||
>
|
||||
<div class="rounded-[10px] shadow-[var(--v2-elevation-raised)] m-2 min-h-0 overflow-hidden bg-v2-background-bg-base self-stretch flex-1">
|
||||
<ScrollView
|
||||
class="h-full [container-type:size]"
|
||||
thumbContainer={sessionThumbTrack}
|
||||
|
||||
@@ -26,7 +26,6 @@ export default function NewLayout(props: ParentProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
data-background-surface="shell"
|
||||
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
||||
style={{
|
||||
"padding-top": "env(safe-area-inset-top, 0px)",
|
||||
|
||||
@@ -2246,10 +2246,7 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-background-surface="shell"
|
||||
class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
|
||||
>
|
||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||
{autoselecting() ?? ""}
|
||||
<Titlebar update={titlebarUpdate} />
|
||||
<Show when={updateVersion() !== undefined}>
|
||||
@@ -2347,7 +2344,6 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
}}
|
||||
>
|
||||
<main
|
||||
data-background-surface="content"
|
||||
classList={{
|
||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||
}}
|
||||
|
||||
@@ -180,11 +180,9 @@ export default function NewSessionPage() {
|
||||
/>
|
||||
<Show when={projectController.selected()}>
|
||||
<div
|
||||
data-background-surface={showWorkspaceBar() ? "workspace-bar" : undefined}
|
||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||
classList={{
|
||||
"flex-col justify-center sm:flex-row": showWorkspaceBar(),
|
||||
"w-fit max-w-full self-center": showWorkspaceBar(),
|
||||
"justify-start": !showWorkspaceBar(),
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -335,7 +335,6 @@ function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
|
||||
function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
|
||||
return (
|
||||
<div
|
||||
data-background-surface={props.newLayout ? "panel" : undefined}
|
||||
classList={{
|
||||
"flex-1 min-h-0 flex flex-col": true,
|
||||
"bg-v2-background-bg-base": props.newLayout,
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
const cacheName = "opencode-background-image-v1"
|
||||
const maxBytes = 20 * 1024 * 1024
|
||||
|
||||
function key() {
|
||||
return new URL("/__opencode/background-image", location.origin).toString()
|
||||
}
|
||||
|
||||
export async function loadWebBackgroundImage() {
|
||||
const response = await (await caches.open(cacheName)).match(key())
|
||||
return response?.blob() ?? null
|
||||
}
|
||||
|
||||
export async function saveWebBackgroundImage(file: File) {
|
||||
if (!file.type.startsWith("image/")) throw new Error("Unsupported background image format")
|
||||
if (file.size > maxBytes) throw new Error("Background images must be 20 MB or smaller")
|
||||
await (await caches.open(cacheName)).put(key(), new Response(file, { headers: { "Content-Type": file.type } }))
|
||||
return file
|
||||
}
|
||||
|
||||
export async function clearWebBackgroundImage() {
|
||||
await (await caches.open(cacheName)).delete(key())
|
||||
}
|
||||
|
||||
export function selectWebBackgroundImage() {
|
||||
return new Promise<File | null>((resolve) => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = "image/avif,image/bmp,image/gif,image/jpeg,image/png,image/webp"
|
||||
input.onchange = () => resolve(input.files?.[0] ?? null)
|
||||
input.oncancel = () => resolve(null)
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
@@ -12,7 +12,16 @@
|
||||
],
|
||||
"exports": {
|
||||
"./daemon": "./src/daemon.ts",
|
||||
"./run": "./src/run/index.ts",
|
||||
"./mini": "./src/mini/index.ts",
|
||||
"./mini/footer.command": "./src/mini/footer.command.tsx",
|
||||
"./mini/footer.menu": "./src/mini/footer.menu.tsx",
|
||||
"./mini/footer.permission": "./src/mini/footer.permission.tsx",
|
||||
"./mini/footer.prompt": "./src/mini/footer.prompt.tsx",
|
||||
"./mini/footer.question": "./src/mini/footer.question.tsx",
|
||||
"./mini/footer.subagent": "./src/mini/footer.subagent.tsx",
|
||||
"./mini/footer.view": "./src/mini/footer.view.tsx",
|
||||
"./mini/scrollback.writer": "./src/mini/scrollback.writer.tsx",
|
||||
"./mini/*": "./src/mini/*.ts",
|
||||
"./server-process": "./src/server-process.ts"
|
||||
},
|
||||
"scripts": {
|
||||
@@ -31,14 +40,18 @@
|
||||
"@opencode-ai/server": "workspace:*",
|
||||
"@opencode-ai/tui": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import childProcess from "node:child_process"
|
||||
import fs from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { createRequire } from "node:module"
|
||||
import { fileURLToPath } from "node:url"
|
||||
|
||||
const directory = path.dirname(fileURLToPath(import.meta.url))
|
||||
const require = createRequire(import.meta.url)
|
||||
const packageJson = JSON.parse(fs.readFileSync(path.join(directory, "package.json"), "utf8"))
|
||||
const command = Object.keys(packageJson.bin ?? {})[0]
|
||||
if (!command) throw new Error("OpenCode package does not declare a binary")
|
||||
|
||||
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()] ?? os.platform()
|
||||
const arch = { x64: "x64", arm64: "arm64", arm: "arm" }[os.arch()] ?? os.arch()
|
||||
const sourceBinary = platform === "windows" ? `${command}.exe` : command
|
||||
const targetBinary = path.resolve(directory, packageJson.bin[command])
|
||||
const dependencies = packageJson.optionalDependencies ?? {}
|
||||
const base = Object.keys(dependencies).find((name) => name.endsWith(`-${platform}-${arch}`))
|
||||
if (!base) throw new Error(`OpenCode does not provide a binary for ${platform}-${arch}`)
|
||||
|
||||
function supportsAvx2() {
|
||||
if (arch !== "x64") return false
|
||||
if (platform === "linux") {
|
||||
try {
|
||||
return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8"))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (platform === "darwin") {
|
||||
try {
|
||||
const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], {
|
||||
encoding: "utf8",
|
||||
timeout: 1500,
|
||||
})
|
||||
return result.status === 0 && (result.stdout || "").trim() === "1"
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (platform === "windows") {
|
||||
const script =
|
||||
'(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)'
|
||||
for (const executable of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) {
|
||||
try {
|
||||
const result = childProcess.spawnSync(executable, ["-NoProfile", "-NonInteractive", "-Command", script], {
|
||||
encoding: "utf8",
|
||||
timeout: 3000,
|
||||
windowsHide: true,
|
||||
})
|
||||
if (result.status !== 0) continue
|
||||
const output = (result.stdout || "").trim().toLowerCase()
|
||||
if (output === "true" || output === "1") return true
|
||||
if (output === "false" || output === "0") return false
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function isMusl() {
|
||||
if (platform !== "linux") return false
|
||||
try {
|
||||
if (fs.existsSync("/etc/alpine-release")) return true
|
||||
const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" })
|
||||
return `${result.stdout || ""}${result.stderr || ""}`.toLowerCase().includes("musl")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function packageNames() {
|
||||
const baseline = arch === "x64" && !supportsAvx2()
|
||||
const names =
|
||||
platform === "linux"
|
||||
? isMusl()
|
||||
? arch === "x64"
|
||||
? baseline
|
||||
? [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base]
|
||||
: [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`]
|
||||
: [`${base}-musl`, base]
|
||||
: arch === "x64"
|
||||
? baseline
|
||||
? [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`]
|
||||
: [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`]
|
||||
: [base, `${base}-musl`]
|
||||
: arch === "x64"
|
||||
? baseline
|
||||
? [`${base}-baseline`, base]
|
||||
: [base, `${base}-baseline`]
|
||||
: [base]
|
||||
return names.filter((name) => dependencies[name])
|
||||
}
|
||||
|
||||
function copyBinary(source) {
|
||||
if (!fs.existsSync(source)) throw new Error(`Binary not found at ${source}`)
|
||||
fs.mkdirSync(path.dirname(targetBinary), { recursive: true })
|
||||
if (fs.existsSync(targetBinary)) fs.unlinkSync(targetBinary)
|
||||
try {
|
||||
fs.linkSync(source, targetBinary)
|
||||
} catch {
|
||||
fs.copyFileSync(source, targetBinary)
|
||||
}
|
||||
fs.chmodSync(targetBinary, 0o755)
|
||||
}
|
||||
|
||||
function resolveBinary(name) {
|
||||
const packagePath = require.resolve(`${name}/package.json`)
|
||||
return path.join(path.dirname(packagePath), "bin", sourceBinary)
|
||||
}
|
||||
|
||||
function installPackage(name) {
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "opencode-install-"))
|
||||
try {
|
||||
const result = childProcess.spawnSync(
|
||||
"npm",
|
||||
["install", "--ignore-scripts", "--no-save", "--loglevel=error", "--prefix", temp, `${name}@${dependencies[name]}`],
|
||||
{ stdio: "inherit", windowsHide: true },
|
||||
)
|
||||
if (result.status !== 0) return false
|
||||
copyBinary(path.join(temp, "node_modules", name, "bin", sourceBinary))
|
||||
return true
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function verifyBinary() {
|
||||
return (
|
||||
childProcess.spawnSync(targetBinary, ["--version"], {
|
||||
stdio: "ignore",
|
||||
windowsHide: true,
|
||||
}).status === 0
|
||||
)
|
||||
}
|
||||
|
||||
function main() {
|
||||
const names = packageNames()
|
||||
for (const name of names) {
|
||||
try {
|
||||
copyBinary(resolveBinary(name))
|
||||
if (verifyBinary()) return
|
||||
} catch {
|
||||
if (installPackage(name) && verifyBinary()) return
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to install OpenCode. Try manually installing ${names.map((name) => JSON.stringify(name)).join(" or ")}.`)
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -32,23 +32,12 @@ async function publishDistribution(input: { root: string; name: string; binary:
|
||||
if (!version) throw new Error(`No binary packages found for ${input.name}`)
|
||||
|
||||
await $`mkdir -p ${input.root}/${input.name}/bin`
|
||||
await $`cp ./script/postinstall.mjs ${input.root}/${input.name}/postinstall.mjs`
|
||||
await Bun.file(`${input.root}/${input.name}/bin/${input.binary}.exe`).write(
|
||||
[
|
||||
`echo "Error: ${input.name}'s postinstall script was not run." >&2`,
|
||||
'echo "" >&2',
|
||||
'echo "This occurs when installation scripts are disabled." >&2',
|
||||
'echo "Run the package postinstall script or reinstall with scripts enabled." >&2',
|
||||
"exit 1",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await $`cp ./bin/opencode2.cjs ${input.root}/${input.name}/bin/${input.binary}`
|
||||
await Bun.file(`${input.root}/${input.name}/package.json`).write(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: input.name,
|
||||
bin: { [input.binary]: `./bin/${input.binary}.exe` },
|
||||
scripts: { postinstall: "node ./postinstall.mjs" },
|
||||
bin: { [input.binary]: `./bin/${input.binary}` },
|
||||
version,
|
||||
license: pkg.license,
|
||||
repository: { type: "git", url: "git+https://github.com/anomalyco/opencode.git" },
|
||||
|
||||
@@ -114,10 +114,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
}),
|
||||
],
|
||||
}),
|
||||
Spec.make("plugin", {
|
||||
description: "Manage plugins",
|
||||
commands: [Spec.make("list", { description: "List active plugins" })],
|
||||
}),
|
||||
Spec.make("migrate", { description: "Migrate v1 data to v2" }),
|
||||
Spec.make("mini", {
|
||||
description: "Start the minimal interactive interface",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { run } from "@opencode-ai/tui"
|
||||
import { Commands } from "../commands"
|
||||
@@ -75,6 +75,6 @@ export default Runtime.handler(Commands, (input) =>
|
||||
: Effect.logInfo(message, tags)
|
||||
runFork(effect)
|
||||
},
|
||||
}).pipe(Effect.provide(LayerNode.compile(Global.node)))
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Context, Effect, FileSystem, Option } from "effect"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
import { ServerConnection } from "../../services/server-connection"
|
||||
import { Config } from "../../config"
|
||||
import { resolve } from "@opencode-ai/tui/config"
|
||||
|
||||
export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -11,17 +9,9 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
yield* Effect.promise(async () => validateMiniTerminal())
|
||||
const serverURL = Option.getOrUndefined(input.server)
|
||||
const server = yield* ServerConnection.resolve({ server: serverURL, standalone: input.standalone })
|
||||
const config = yield* Config.Service
|
||||
const resolved = resolve(yield* config.get(), { terminalSuspend: process.platform !== "win32" })
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
const runServicePromise = Effect.runPromiseWith(Context.make(FileSystem.FileSystem, fileSystem))
|
||||
const service = server.service
|
||||
yield* Effect.promise(() =>
|
||||
runMini({
|
||||
server: {
|
||||
endpoint: server.endpoint,
|
||||
reconnect: service ? (signal) => runServicePromise(service.reconnect(), { signal }) : undefined,
|
||||
},
|
||||
server,
|
||||
continue: input.continue,
|
||||
session: Option.getOrUndefined(input.session),
|
||||
fork: input.fork,
|
||||
@@ -31,7 +21,6 @@ export default Runtime.handler(Commands.commands.mini, (input) =>
|
||||
replay: input.replay,
|
||||
replayLimit: Option.getOrUndefined(input.replayLimit),
|
||||
demo: input.demo,
|
||||
tuiConfig: resolved,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { EOL } from "node:os"
|
||||
import { Effect } from "effect"
|
||||
import { OpenCode } from "@opencode-ai/client"
|
||||
import { Service } from "@opencode-ai/client/effect/service"
|
||||
import { Commands } from "../../commands"
|
||||
import { Runtime } from "../../../framework/runtime"
|
||||
import { ServiceConfig } from "../../../services/service-config"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.plugin.commands.list,
|
||||
Effect.fn("cli.plugin.list")(function* () {
|
||||
const options = yield* ServiceConfig.options()
|
||||
const found = yield* Service.discover(options)
|
||||
const endpoint = found ?? (yield* Service.ensure(options))
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const response = yield* Effect.promise(() => client.plugin.list({ location: { directory: process.cwd() } }))
|
||||
const plugins = response.data.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
if (plugins.length === 0) {
|
||||
process.stdout.write("No plugins loaded" + EOL)
|
||||
return
|
||||
}
|
||||
process.stdout.write(plugins.map((plugin) => plugin.id).join(EOL) + EOL)
|
||||
}),
|
||||
)
|
||||
@@ -5,7 +5,7 @@ import { ServerConnection } from "../../services/server-connection"
|
||||
|
||||
export default Runtime.handler(Commands.commands.run, (input) =>
|
||||
Effect.gen(function* () {
|
||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../run/run"))
|
||||
const { runNonInteractive } = yield* Effect.promise(() => import("../../mini"))
|
||||
const separator = process.argv.indexOf("--", 2)
|
||||
const server = yield* ServerConnection.resolve({
|
||||
server: Option.getOrUndefined(input.server),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Runtime } from "./framework/runtime"
|
||||
import { Observability } from "@opencode-ai/core/observability"
|
||||
import { Updater } from "./services/updater"
|
||||
import { InstallationChannel, InstallationVersion, InstallationLocal } from "@opencode-ai/core/installation/version"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
@@ -31,9 +32,6 @@ const Handlers = Runtime.handlers(Commands, {
|
||||
auth: () => import("./commands/handlers/mcp/auth"),
|
||||
logout: () => import("./commands/handlers/mcp/logout"),
|
||||
},
|
||||
plugin: {
|
||||
list: () => import("./commands/handlers/plugin/list"),
|
||||
},
|
||||
migrate: () => import("./commands/handlers/migrate"),
|
||||
mini: () => import("./commands/handlers/mini"),
|
||||
run: () => import("./commands/handlers/run"),
|
||||
@@ -60,7 +58,7 @@ Effect.logInfo("cli starting", {
|
||||
Effect.annotateLogs({ role: "cli" }),
|
||||
Effect.provide(Config.layer),
|
||||
Effect.provide(Updater.layer),
|
||||
Effect.provide(LayerNode.compile(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
|
||||
Effect.provide(AppNodeBuilder.build(LayerNode.group([Global.node, AppProcess.node, Npm.node]))),
|
||||
Effect.provide(Observability.layer),
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||
import { createModelPreferenceRepository } from "@opencode-ai/tui/model-preference"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import fs from "node:fs"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { ReadStream } from "node:tty"
|
||||
|
||||
export const INTERACTIVE_INPUT_ERROR = "opencode mini requires a controlling terminal for input"
|
||||
|
||||
export type InteractiveStdin = {
|
||||
stdin: NodeJS.ReadStream
|
||||
cleanup(): void
|
||||
}
|
||||
|
||||
type MiniHost = MiniFrontendInput["host"]
|
||||
|
||||
function preferences(statePath: string): MiniHost["preferences"] {
|
||||
const repository = createModelPreferenceRepository(path.join(statePath, "model.json"))
|
||||
return {
|
||||
async resolveVariant(model) {
|
||||
if (!model) return
|
||||
return repository.resolveVariant(model)
|
||||
},
|
||||
async saveVariant(model, variant) {
|
||||
if (!model) return
|
||||
await repository.saveVariant(model, variant).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function signal(name: "SIGINT" | "SIGUSR2"): MiniHost["signals"]["sigint"] {
|
||||
return {
|
||||
subscribe(listener) {
|
||||
let subscribed = true
|
||||
process.on(name, listener)
|
||||
return () => {
|
||||
if (!subscribed) return
|
||||
subscribed = false
|
||||
process.off(name, listener)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createTrace(
|
||||
logPath: string,
|
||||
diagnostics: { pid: number; cwd: string; argv: string[] },
|
||||
): MiniHost["diagnostics"]["trace"] {
|
||||
if (!process.env.OPENCODE_DIRECT_TRACE) return
|
||||
const stamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[-:]/g, "")
|
||||
.replace(/\.\d+Z$/, "Z")
|
||||
const target = path.join(logPath, "direct", `${stamp}-${diagnostics.pid}.jsonl`)
|
||||
const text = (data: unknown) =>
|
||||
JSON.stringify(data, (_key, value) => (typeof value === "bigint" ? String(value) : value), 0)
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(logPath, "direct", "latest.json"),
|
||||
text({
|
||||
time: new Date().toISOString(),
|
||||
...diagnostics,
|
||||
path: target,
|
||||
}) + "\n",
|
||||
)
|
||||
const trace = {
|
||||
write(type: string, data?: unknown) {
|
||||
fs.appendFileSync(
|
||||
target,
|
||||
text({
|
||||
time: new Date().toISOString(),
|
||||
pid: diagnostics.pid,
|
||||
type,
|
||||
data,
|
||||
}) + "\n",
|
||||
)
|
||||
},
|
||||
}
|
||||
trace.write("trace.start", {
|
||||
argv: diagnostics.argv,
|
||||
cwd: diagnostics.cwd,
|
||||
path: target,
|
||||
})
|
||||
return trace
|
||||
}
|
||||
|
||||
function openTerminalStdin(target: string): NodeJS.ReadStream {
|
||||
return new ReadStream(fs.openSync(target, "r"))
|
||||
}
|
||||
|
||||
export function resolveInteractiveStdin(
|
||||
stdin: NodeJS.ReadStream = process.stdin,
|
||||
open: (target: string) => NodeJS.ReadStream = openTerminalStdin,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): InteractiveStdin {
|
||||
if (stdin.isTTY) return { stdin, cleanup() {} }
|
||||
const target = platform === "win32" ? "CONIN$" : "/dev/tty"
|
||||
try {
|
||||
const source = open(target)
|
||||
let cleaned = false
|
||||
return {
|
||||
stdin: source,
|
||||
cleanup() {
|
||||
if (cleaned) return
|
||||
cleaned = true
|
||||
source.destroy()
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(INTERACTIVE_INPUT_ERROR, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for owner-local resource cleanup tests. */
|
||||
export async function usingInteractiveStdin<T>(
|
||||
run: (terminal: InteractiveStdin) => Promise<T>,
|
||||
resolve: () => InteractiveStdin = resolveInteractiveStdin,
|
||||
) {
|
||||
const terminal = resolve()
|
||||
try {
|
||||
return await run(terminal)
|
||||
} finally {
|
||||
terminal.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for owner-local host capability tests. */
|
||||
export function createMiniHost(input: {
|
||||
terminal: InteractiveStdin
|
||||
directory: string
|
||||
paths?: { home: string; state: string; log: string }
|
||||
}): MiniHost {
|
||||
const paths = input.paths ?? {
|
||||
home: Global.Path.home,
|
||||
state: Global.Path.state,
|
||||
log: Global.Path.log,
|
||||
}
|
||||
const diagnostics = {
|
||||
pid: process.pid,
|
||||
cwd: input.directory,
|
||||
argv: process.argv.slice(2),
|
||||
}
|
||||
return {
|
||||
terminal: { stdin: input.terminal.stdin },
|
||||
platform: process.platform,
|
||||
stdout: {
|
||||
write(value) {
|
||||
process.stdout.write(value)
|
||||
},
|
||||
},
|
||||
files: {
|
||||
readText: (url) => readFile(new URL(url), "utf8"),
|
||||
},
|
||||
editor: {
|
||||
async open(options) {
|
||||
const { openEditor } = await import("@opencode-ai/tui/editor")
|
||||
return openEditor(options)
|
||||
},
|
||||
},
|
||||
paths: { home: paths.home },
|
||||
signals: {
|
||||
sigint: signal("SIGINT"),
|
||||
sigusr2: signal("SIGUSR2"),
|
||||
},
|
||||
startup: {
|
||||
showTiming: Flag.OPENCODE_SHOW_TTFD,
|
||||
now: () => performance.now(),
|
||||
},
|
||||
diagnostics: {
|
||||
trace: createTrace(paths.log, diagnostics),
|
||||
},
|
||||
preferences: preferences(paths.state),
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
import { waitForCatalogReady } from "./services/catalog"
|
||||
import { readStdin } from "./util/io"
|
||||
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
|
||||
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
|
||||
|
||||
export type MiniCommandInput = {
|
||||
server: {
|
||||
endpoint: Endpoint
|
||||
reconnect?: (signal: AbortSignal) => Promise<Endpoint>
|
||||
}
|
||||
continue?: boolean
|
||||
session?: string
|
||||
fork?: boolean
|
||||
model?: string
|
||||
agent?: string
|
||||
prompt?: string
|
||||
replay?: boolean
|
||||
replayLimit?: number
|
||||
demo?: boolean
|
||||
tuiConfig?: MiniFrontendInput["tuiConfig"]
|
||||
}
|
||||
|
||||
type Model = MiniFrontendInput["model"]
|
||||
|
||||
class MiniInputError extends Error {}
|
||||
|
||||
export async function runMini(input: MiniCommandInput) {
|
||||
try {
|
||||
validate(input)
|
||||
const result = await usingInteractiveStdin(async (terminal) => {
|
||||
const initialInput = mergeInput(process.stdin.isTTY ? undefined : await readStdin(), input.prompt)
|
||||
const frontendTask = import("@opencode-ai/tui/mini")
|
||||
const directory = localDirectory()
|
||||
const connection = createMiniConnection(input.server)
|
||||
const sdk = connection.sdk
|
||||
const requested = parseModel(input.model)
|
||||
const model = requested ? { providerID: requested.providerID, modelID: requested.id } : undefined
|
||||
const prepare = prepareTarget(input.agent)
|
||||
const resolveTarget = async (initial: OpenCodeClient, signal: AbortSignal) => {
|
||||
const resolved = await resolveMiniTarget({
|
||||
sdk: initial,
|
||||
reconnect: connection.reconnect,
|
||||
signal,
|
||||
resolve: (client) =>
|
||||
resolveSessionTarget({
|
||||
client,
|
||||
location: { directory },
|
||||
continue: input.continue,
|
||||
session: input.session,
|
||||
fork: input.fork,
|
||||
model: requested,
|
||||
agent: input.agent,
|
||||
prepare,
|
||||
signal,
|
||||
}).catch((error) => {
|
||||
if (error instanceof Error && error.message === "Session not found")
|
||||
throw new MiniInputError(error.message)
|
||||
throw error
|
||||
}),
|
||||
})
|
||||
const target = resolved.value
|
||||
return {
|
||||
sdk: resolved.sdk,
|
||||
sessionID: target.session.id,
|
||||
sessionTitle: target.session.title,
|
||||
location: target.location,
|
||||
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
|
||||
variant: target.model?.variant,
|
||||
agent: target.agent,
|
||||
resume: target.resume,
|
||||
}
|
||||
}
|
||||
const create = (
|
||||
client: OpenCodeClient,
|
||||
next: {
|
||||
location: { directory: string; workspaceID?: string }
|
||||
agent: string | undefined
|
||||
model: Model
|
||||
variant: string | undefined
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
) =>
|
||||
resolveSessionTarget({
|
||||
client,
|
||||
location: { directory: next.location.directory, workspace: next.location.workspaceID },
|
||||
agent: next.agent,
|
||||
model: next.model
|
||||
? { providerID: next.model.providerID, id: next.model.modelID, variant: next.variant }
|
||||
: undefined,
|
||||
prepare,
|
||||
signal,
|
||||
}).then((target) => ({
|
||||
sessionID: target.session.id,
|
||||
sessionTitle: target.session.title,
|
||||
location: target.location,
|
||||
model: target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined,
|
||||
variant: target.model?.variant,
|
||||
agent: target.agent,
|
||||
resume: false,
|
||||
}))
|
||||
const frontend = await frontendTask
|
||||
return frontend.runMiniFrontend({
|
||||
host: createMiniHost({ terminal, directory }),
|
||||
sdk,
|
||||
directory,
|
||||
target: resolveTarget,
|
||||
reconnect: connection.reconnect,
|
||||
createSession: create,
|
||||
agent: input.agent,
|
||||
model,
|
||||
variant: requested?.variant,
|
||||
files: [],
|
||||
initialInput,
|
||||
replay: input.replay ?? true,
|
||||
replayLimit: input.replayLimit,
|
||||
demo: input.demo,
|
||||
tuiConfig: input.tuiConfig,
|
||||
})
|
||||
})
|
||||
if (result.exitCode !== 0) process.exit(result.exitCode)
|
||||
} catch (error) {
|
||||
if (error instanceof MiniInputError || (error instanceof Error && error.message === INTERACTIVE_INPUT_ERROR))
|
||||
fail(error.message)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for CLI boundary tests. */
|
||||
export function createMiniConnection(input: MiniCommandInput["server"]) {
|
||||
const make = (endpoint: Endpoint) =>
|
||||
OpenCode.make({
|
||||
baseUrl: endpoint.url,
|
||||
headers: Service.headers(endpoint),
|
||||
})
|
||||
const reconnect = input.reconnect
|
||||
return {
|
||||
sdk: make(input.endpoint),
|
||||
reconnect: reconnect
|
||||
? async (signal: AbortSignal) => {
|
||||
const endpoint = await reconnect(signal)
|
||||
return make(endpoint)
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal Exported for reconnect lifecycle tests. */
|
||||
export async function resolveMiniTarget<A>(input: {
|
||||
sdk: OpenCodeClient
|
||||
reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
|
||||
signal: AbortSignal
|
||||
resolve: (sdk: OpenCodeClient) => Promise<A>
|
||||
}) {
|
||||
let sdk = input.sdk
|
||||
while (true) {
|
||||
try {
|
||||
return { sdk, value: await input.resolve(sdk) }
|
||||
} catch (error) {
|
||||
if (!input.reconnect || !(error instanceof ClientError) || error.reason !== "Transport") throw error
|
||||
while (true) {
|
||||
try {
|
||||
sdk = await input.reconnect(input.signal)
|
||||
break
|
||||
} catch (resolveError) {
|
||||
if (input.signal.aborted) throw resolveError
|
||||
await setTimeout(250, undefined, { signal: input.signal })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function validateMiniTerminal() {
|
||||
if (!process.stdout.isTTY) fail("opencode mini requires a TTY stdout")
|
||||
}
|
||||
|
||||
/** @internal Exported for testing. */
|
||||
export function mergeInput(piped: string | undefined, prompt: string | undefined) {
|
||||
if (!prompt) return piped || undefined
|
||||
if (!piped) return prompt
|
||||
return piped + "\n" + prompt
|
||||
}
|
||||
|
||||
function validate(input: MiniCommandInput) {
|
||||
validateMiniTerminal()
|
||||
if (input.replayLimit !== undefined && (!Number.isInteger(input.replayLimit) || input.replayLimit <= 0)) {
|
||||
fail("--replay-limit must be a positive integer")
|
||||
}
|
||||
if (input.fork && !input.continue && !input.session) fail("--fork requires --continue or --session")
|
||||
}
|
||||
|
||||
function localDirectory(): string {
|
||||
const root = process.env.PWD ?? process.cwd()
|
||||
try {
|
||||
process.chdir(root)
|
||||
return process.cwd()
|
||||
} catch {
|
||||
throw new MiniInputError(`Failed to change directory to ${root}`)
|
||||
}
|
||||
}
|
||||
|
||||
function parseModel(value?: string) {
|
||||
try {
|
||||
return parseSessionTargetModel(value)
|
||||
} catch {
|
||||
throw new MiniInputError("--model must use the format provider/model[#variant]")
|
||||
}
|
||||
}
|
||||
|
||||
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
|
||||
return async (input) => {
|
||||
if (input.model)
|
||||
await waitForCatalogReady({
|
||||
sdk: input.client,
|
||||
directory: input.location.directory,
|
||||
workspace: input.location.workspaceID,
|
||||
model: { providerID: input.model.providerID, modelID: input.model.id },
|
||||
signal: input.signal,
|
||||
})
|
||||
return {
|
||||
model: input.model,
|
||||
agent: requestedAgent
|
||||
? await validateAgent(
|
||||
input.client,
|
||||
input.location.directory,
|
||||
input.location.workspaceID,
|
||||
requestedAgent,
|
||||
input.signal,
|
||||
)
|
||||
: input.agent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAgent(
|
||||
sdk: OpenCodeClient,
|
||||
directory: string,
|
||||
workspace: string | undefined,
|
||||
name?: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (!name) return
|
||||
const deadline = Date.now() + 5_000
|
||||
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
||||
while (Date.now() < deadline && !signal?.aborted) {
|
||||
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
|
||||
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
|
||||
return undefined
|
||||
})
|
||||
const agent = agents?.data.find((item) => item.id === name)
|
||||
if (agent?.mode === "subagent") {
|
||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
if (agent) return name
|
||||
await setTimeout(25, undefined, { signal }).catch(() => {})
|
||||
}
|
||||
if (signal?.aborted) return
|
||||
if (!agents) {
|
||||
warning("failed to list agents. Falling back to default agent")
|
||||
return
|
||||
}
|
||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||
}
|
||||
|
||||
function warning(message: string) {
|
||||
process.stderr.write(`\x1b[93m\x1b[1m!\x1b[0m ${message}\n`)
|
||||
}
|
||||
|
||||
function fail(message: string): never {
|
||||
process.stderr.write(`\x1b[91m\x1b[1mError: \x1b[0m${message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
AgentListOutput,
|
||||
CommandListOutput,
|
||||
ModelListOutput,
|
||||
OpenCodeClient,
|
||||
ProviderListOutput,
|
||||
SkillListOutput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { RunAgent, RunCommand, RunProvider, RunReference } from "./types"
|
||||
|
||||
type CurrentAgent = AgentListOutput["data"][number]
|
||||
type CurrentCommand = CommandListOutput["data"][number]
|
||||
type CurrentSkill = SkillListOutput["data"][number]
|
||||
type CurrentProvider = ProviderListOutput["data"][number]
|
||||
type CurrentModel = ModelListOutput["data"][number]
|
||||
|
||||
function location(directory: string, workspace?: string) {
|
||||
return {
|
||||
location: {
|
||||
directory,
|
||||
workspace,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function defaultCost(model: CurrentModel) {
|
||||
const picked = model.cost.find((cost) => cost.tier === undefined) ?? model.cost[0]
|
||||
if (!picked) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
...picked,
|
||||
input: model.cost.every((cost) => cost.input === 0) ? 0 : picked.input,
|
||||
}
|
||||
}
|
||||
|
||||
export function runAgent(input: CurrentAgent): RunAgent {
|
||||
return {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
mode: input.mode,
|
||||
hidden: input.hidden,
|
||||
}
|
||||
}
|
||||
|
||||
export function runCommand(input: CurrentCommand): RunCommand {
|
||||
return {
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
}
|
||||
}
|
||||
|
||||
export function runSkill(input: CurrentSkill): RunCommand {
|
||||
return {
|
||||
name: input.id,
|
||||
description: input.description,
|
||||
source: "skill",
|
||||
}
|
||||
}
|
||||
|
||||
export function runProviders(providers: CurrentProvider[], models: CurrentModel[]): RunProvider[] {
|
||||
const grouped = new Map<string, RunProvider>()
|
||||
|
||||
for (const provider of providers) {
|
||||
grouped.set(provider.id, {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
models: {},
|
||||
})
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
const provider = grouped.get(model.providerID) ?? {
|
||||
id: model.providerID,
|
||||
name: model.providerID,
|
||||
models: {},
|
||||
}
|
||||
provider.models[model.id] = {
|
||||
id: model.id,
|
||||
providerID: model.providerID,
|
||||
name: model.name,
|
||||
capabilities: model.capabilities,
|
||||
cost: defaultCost(model),
|
||||
limit: model.limit,
|
||||
status: model.status,
|
||||
variants: Object.fromEntries((model.variants ?? []).map((variant) => [variant.id, {}])),
|
||||
}
|
||||
grouped.set(provider.id, provider)
|
||||
}
|
||||
|
||||
return [...grouped.values()]
|
||||
}
|
||||
|
||||
// A location boots its plugins in a deferred background batch after the layer
|
||||
// is built, so first-turn model resolution can observe empty catalog state.
|
||||
// For explicit --model flows, wait for that exact ref to appear before prompt
|
||||
// admission. On timeout, return and let the real execution error surface.
|
||||
export async function waitForCatalogReady(input: {
|
||||
sdk: OpenCodeClient
|
||||
directory: string
|
||||
workspace?: string
|
||||
model: { providerID: string; modelID: string }
|
||||
timeoutMs?: number
|
||||
}) {
|
||||
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
||||
while (Date.now() < deadline) {
|
||||
const models = await input.sdk.model
|
||||
.list(location(input.directory, input.workspace))
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForDefaultModel(input: {
|
||||
sdk: OpenCodeClient
|
||||
directory: string
|
||||
timeoutMs?: number
|
||||
active?: () => boolean
|
||||
}): Promise<{ providerID: string; modelID: string } | undefined> {
|
||||
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
||||
while (Date.now() < deadline && (input.active?.() ?? true)) {
|
||||
const model = await input.sdk.model
|
||||
.default(location(input.directory))
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined)
|
||||
if (model) return { providerID: model.providerID, modelID: model.id }
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRunAgents(sdk: OpenCodeClient, directory: string): Promise<RunAgent[]> {
|
||||
const result = await sdk.agent.list(location(directory))
|
||||
return result.data.map(runAgent)
|
||||
}
|
||||
|
||||
export async function loadRunCommands(sdk: OpenCodeClient, directory: string): Promise<RunCommand[]> {
|
||||
const [commands, skills] = await Promise.all([
|
||||
sdk.command.list(location(directory)),
|
||||
sdk.skill.list(location(directory)),
|
||||
])
|
||||
return [...commands.data.map(runCommand), ...skills.data.filter((skill) => skill.slash !== false).map(runSkill)]
|
||||
}
|
||||
|
||||
export async function loadRunReferences(sdk: OpenCodeClient, directory: string): Promise<RunReference[]> {
|
||||
const result = await sdk.reference.list(location(directory))
|
||||
return result.data.filter((reference) => !reference.hidden)
|
||||
}
|
||||
|
||||
export async function loadRunProviders(sdk: OpenCodeClient, directory: string): Promise<RunProvider[]> {
|
||||
const [providers, models] = await Promise.all([
|
||||
sdk.provider.list(location(directory)),
|
||||
sdk.model.list(location(directory)),
|
||||
])
|
||||
return runProviders([...providers.data], [...models.data])
|
||||
}
|
||||
@@ -2,30 +2,28 @@
|
||||
//
|
||||
// Enabled with `--demo`. Intercepts prompt submissions and drives the same
|
||||
// presentation commits and footer actions as the live transport. This
|
||||
// lets you test scrollback formatting, permission UI, canonical Form UI, and tool
|
||||
// lets you test scrollback formatting, permission UI, question UI, and tool
|
||||
// snapshots without making actual model calls. Pass a demo slash command as
|
||||
// the initial interactive message to trigger a preview immediately.
|
||||
//
|
||||
// Slash commands:
|
||||
// /permission [kind] → triggers a permission request variant
|
||||
// /form [kind] → triggers a canonical Form request variant
|
||||
// /question [kind] → triggers a question request variant
|
||||
// /fmt <kind> → emits a specific tool/text type (text, reasoning, shell,
|
||||
// write, edit, patch, subagent, question, error, mix)
|
||||
// write, edit, patch, task, question, error, mix)
|
||||
//
|
||||
// Demo mode handles permission and Form replies locally, completing or failing
|
||||
// the synthetic tool parts through the same callbacks used by the live footer.
|
||||
// Demo mode also handles permission and question replies locally, completing
|
||||
// or failing the synthetic tool parts as appropriate.
|
||||
import path from "path"
|
||||
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import type { PermissionV2Request, QuestionV2Request } from "@opencode-ai/client/promise"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { toolCommit } from "./stream-v2.subagent"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterView,
|
||||
FormCancel,
|
||||
FormReply,
|
||||
MiniFormRequest,
|
||||
MiniPermissionRequest,
|
||||
MiniToolPart,
|
||||
PermissionReply,
|
||||
QuestionReject,
|
||||
QuestionReply,
|
||||
RunPrompt,
|
||||
StreamCommit,
|
||||
} from "./types"
|
||||
@@ -39,25 +37,25 @@ const KINDS = [
|
||||
"write",
|
||||
"edit",
|
||||
"patch",
|
||||
"subagent",
|
||||
"task",
|
||||
"question",
|
||||
"error",
|
||||
"mix",
|
||||
]
|
||||
const PERMISSIONS = ["edit", "shell", "read", "subagent", "external", "doom"] as const
|
||||
const FORMS = ["question", "external"] as const
|
||||
const PERMISSIONS = ["edit", "shell", "read", "task", "external", "doom"] as const
|
||||
const QUESTIONS = ["multi", "single", "checklist", "custom"] as const
|
||||
|
||||
type PermissionKind = (typeof PERMISSIONS)[number]
|
||||
type FormKind = (typeof FORMS)[number]
|
||||
type QuestionKind = (typeof QUESTIONS)[number]
|
||||
|
||||
function permissionKind(value: string | undefined): PermissionKind | undefined {
|
||||
const next = (value || "edit").toLowerCase()
|
||||
return PERMISSIONS.find((item) => item === next)
|
||||
}
|
||||
|
||||
function formKind(value: string | undefined): FormKind | undefined {
|
||||
const next = (value || "question").toLowerCase()
|
||||
return FORMS.find((item) => item === next)
|
||||
function questionKind(value: string | undefined): QuestionKind | undefined {
|
||||
const next = (value || "multi").toLowerCase()
|
||||
return QUESTIONS.find((item) => item === next)
|
||||
}
|
||||
|
||||
const SAMPLE_MARKDOWN = [
|
||||
@@ -113,14 +111,12 @@ type Ref = {
|
||||
part: string
|
||||
call: string
|
||||
tool: string
|
||||
input: Record<string, JsonValue>
|
||||
input: Record<string, unknown>
|
||||
start: number
|
||||
}
|
||||
|
||||
type FormRequest = {
|
||||
type Ask = {
|
||||
ref: Ref
|
||||
kind: FormKind
|
||||
request: MiniFormRequest
|
||||
}
|
||||
|
||||
type Perm = {
|
||||
@@ -128,7 +124,7 @@ type Perm = {
|
||||
done: {
|
||||
title: string
|
||||
output: string
|
||||
metadata?: Record<string, JsonValue>
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +132,7 @@ type Permit = {
|
||||
ref: Ref
|
||||
permission: string
|
||||
patterns: string[]
|
||||
metadata?: MiniPermissionRequest["metadata"]
|
||||
metadata?: PermissionV2Request["metadata"]
|
||||
always: string[]
|
||||
done: Perm["done"]
|
||||
}
|
||||
@@ -149,9 +145,9 @@ type State = {
|
||||
part: number
|
||||
call: number
|
||||
perm: number
|
||||
form: number
|
||||
ask: number
|
||||
perms: Map<string, Perm>
|
||||
forms: Map<string, FormRequest>
|
||||
asks: Map<string, Ask>
|
||||
started: Set<string>
|
||||
}
|
||||
|
||||
@@ -177,7 +173,7 @@ function clearSubagent(footer: FooterApi): void {
|
||||
tabs: [],
|
||||
details: {},
|
||||
permissions: [],
|
||||
forms: [],
|
||||
questions: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -186,10 +182,13 @@ function showSubagent(
|
||||
state: State,
|
||||
input: {
|
||||
sessionID: string
|
||||
partID: string
|
||||
callID: string
|
||||
label: string
|
||||
description: string
|
||||
status: "running" | "completed" | "cancelled" | "error"
|
||||
title?: string
|
||||
toolCalls?: number
|
||||
commits: StreamCommit[]
|
||||
},
|
||||
) {
|
||||
@@ -199,20 +198,24 @@ function showSubagent(
|
||||
tabs: [
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
partID: input.partID,
|
||||
callID: input.callID,
|
||||
label: input.label,
|
||||
description: input.description,
|
||||
status: input.status,
|
||||
title: input.title,
|
||||
toolCalls: input.toolCalls,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
[input.sessionID]: {
|
||||
sessionID: input.sessionID,
|
||||
commits: input.commits,
|
||||
},
|
||||
},
|
||||
permissions: [],
|
||||
forms: [],
|
||||
questions: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -253,24 +256,21 @@ function split(text: string): string[] {
|
||||
return [text.slice(0, size), text.slice(size, size * 2), text.slice(size * 2)]
|
||||
}
|
||||
|
||||
function take(state: State, key: "msg" | "part" | "call" | "perm", prefix: string): string {
|
||||
function take(state: State, key: "msg" | "part" | "call" | "perm" | "ask", prefix: string): string {
|
||||
state[key] += 1
|
||||
return `demo_${prefix}_${state[key]}`
|
||||
}
|
||||
|
||||
function present(state: State, commits: StreamCommit[], view?: FooterView): void {
|
||||
function present(state: State, commits: StreamCommit[], view?: QuestionV2Request | PermissionV2Request): void {
|
||||
writeSessionOutput(
|
||||
{ footer: state.footer },
|
||||
{
|
||||
commits,
|
||||
updates: view
|
||||
? [
|
||||
{
|
||||
type: "stream.patch" as const,
|
||||
patch: { status: view.type === "permission" ? "awaiting permission" : "awaiting form" },
|
||||
},
|
||||
{ type: "stream.view" as const, view },
|
||||
]
|
||||
footer: view
|
||||
? {
|
||||
view: "action" in view ? { type: "permission", request: view } : { type: "question", request: view },
|
||||
patch: { status: "action" in view ? "awaiting permission" : "awaiting answer" },
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
)
|
||||
@@ -279,13 +279,7 @@ function present(state: State, commits: StreamCommit[], view?: FooterView): void
|
||||
function clearBlocker(state: State): void {
|
||||
writeSessionOutput(
|
||||
{ footer: state.footer },
|
||||
{
|
||||
commits: [],
|
||||
updates: [
|
||||
{ type: "stream.patch", patch: { status: "" } },
|
||||
{ type: "stream.view", view: { type: "prompt" } },
|
||||
],
|
||||
},
|
||||
{ commits: [], footer: { view: { type: "prompt" }, patch: { status: "" } } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -301,9 +295,7 @@ async function emitText(state: State, body: string, signal?: AbortSignal): Promi
|
||||
return
|
||||
}
|
||||
|
||||
present(state, [
|
||||
{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part },
|
||||
])
|
||||
present(state, [{ kind: "assistant", source: "assistant", text: item, phase: "progress", messageID: msg, partID: part }])
|
||||
await wait(45, signal)
|
||||
}
|
||||
}
|
||||
@@ -334,7 +326,7 @@ async function emitReasoning(state: State, body: string, signal?: AbortSignal):
|
||||
}
|
||||
}
|
||||
|
||||
function make(state: State, tool: string, input: Record<string, JsonValue>): Ref {
|
||||
function make(state: State, tool: string, input: Record<string, unknown>): Ref {
|
||||
return {
|
||||
msg: open(state),
|
||||
part: take(state, "part", "part"),
|
||||
@@ -345,21 +337,28 @@ function make(state: State, tool: string, input: Record<string, JsonValue>): Ref
|
||||
}
|
||||
}
|
||||
|
||||
function startTool(state: State, ref: Ref, structured: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
|
||||
function startTool(state: State, ref: Ref, metadata: Record<string, unknown> = {}): void {
|
||||
state.started.add(ref.part)
|
||||
const part = {
|
||||
type: "tool" as const,
|
||||
id: ref.call,
|
||||
name: ref.tool,
|
||||
state: { status: "running" as const, input: ref.input, structured, content: [] },
|
||||
time: { created: ref.start, ran: ref.start },
|
||||
}
|
||||
present(state, [toolCommit(part, ref.msg, "start")])
|
||||
return part
|
||||
present(
|
||||
state,
|
||||
[
|
||||
toolCommit(
|
||||
{
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: { status: "running", input: ref.input, metadata, time: { start: ref.start } },
|
||||
},
|
||||
"start",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
function askPermission(state: State, item: Permit): void {
|
||||
const tool = startTool(state, item.ref)
|
||||
startTool(state, item.ref)
|
||||
|
||||
const id = take(state, "perm", "perm")
|
||||
state.perms.set(id, {
|
||||
@@ -368,17 +367,13 @@ function askPermission(state: State, item: Permit): void {
|
||||
})
|
||||
|
||||
present(state, [], {
|
||||
type: "permission",
|
||||
request: {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
action: item.permission,
|
||||
resources: item.patterns,
|
||||
metadata: item.metadata ?? {},
|
||||
save: item.always,
|
||||
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
|
||||
tool,
|
||||
},
|
||||
id,
|
||||
sessionID: state.id,
|
||||
action: item.permission,
|
||||
resources: item.patterns,
|
||||
metadata: item.metadata ?? {},
|
||||
save: item.always,
|
||||
source: { type: "tool", messageID: item.ref.msg, callID: item.ref.call },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -388,46 +383,52 @@ function doneTool(
|
||||
output: {
|
||||
title: string
|
||||
output: string
|
||||
metadata?: Record<string, JsonValue>
|
||||
metadata?: Record<string, unknown>
|
||||
},
|
||||
): void {
|
||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||
const part: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: ref.call,
|
||||
name: ref.tool,
|
||||
const part: MiniToolPart = {
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: ref.input,
|
||||
content: output.output ? [{ type: "text", text: output.output }] : [],
|
||||
structured: output.metadata ?? {},
|
||||
output: output.output,
|
||||
title: output.title,
|
||||
metadata: output.metadata ?? {},
|
||||
time: { start: ref.start, end: Date.now() },
|
||||
},
|
||||
time: { created: ref.start, ran: ref.start, completed: Date.now() },
|
||||
}
|
||||
present(state, [toolCommit(part, ref.msg, output.output ? "progress" : "final")])
|
||||
present(state, [toolCommit(part, output.output ? "progress" : "final")])
|
||||
}
|
||||
|
||||
function failTool(state: State, ref: Ref, error: string): void {
|
||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||
present(state, [
|
||||
toolCommit(
|
||||
{
|
||||
type: "tool",
|
||||
id: ref.call,
|
||||
name: ref.tool,
|
||||
state: {
|
||||
status: "error",
|
||||
input: ref.input,
|
||||
error: { type: "unknown", message: error },
|
||||
structured: {},
|
||||
content: [],
|
||||
present(
|
||||
state,
|
||||
[
|
||||
toolCommit(
|
||||
{
|
||||
id: ref.part,
|
||||
sessionID: state.id,
|
||||
messageID: ref.msg,
|
||||
callID: ref.call,
|
||||
tool: ref.tool,
|
||||
state: {
|
||||
status: "error",
|
||||
input: ref.input,
|
||||
error,
|
||||
metadata: {},
|
||||
time: { start: ref.start, end: Date.now() },
|
||||
},
|
||||
},
|
||||
time: { created: ref.start, ran: ref.start, completed: Date.now() },
|
||||
},
|
||||
ref.msg,
|
||||
"final",
|
||||
),
|
||||
])
|
||||
"final",
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
function emitError(state: State, text: string): void {
|
||||
@@ -446,7 +447,7 @@ async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
|
||||
title: "git status",
|
||||
output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
|
||||
metadata: {
|
||||
exit: 0,
|
||||
exitCode: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -454,7 +455,7 @@ async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
|
||||
function emitWrite(state: State): void {
|
||||
const file = path.join(process.cwd(), "src", "demo-format.ts")
|
||||
const ref = make(state, "write", {
|
||||
path: file,
|
||||
filePath: file,
|
||||
content: "export const demo = 42\n",
|
||||
})
|
||||
doneTool(state, ref, {
|
||||
@@ -467,19 +468,13 @@ function emitWrite(state: State): void {
|
||||
function emitEdit(state: State): void {
|
||||
const file = path.join(process.cwd(), "src", "demo-format.ts")
|
||||
const ref = make(state, "edit", {
|
||||
path: file,
|
||||
filePath: file,
|
||||
})
|
||||
doneTool(state, ref, {
|
||||
title: "edit",
|
||||
output: "",
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
file,
|
||||
status: "modified",
|
||||
patch: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
||||
},
|
||||
],
|
||||
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -495,15 +490,17 @@ function emitPatch(state: State): void {
|
||||
metadata: {
|
||||
files: [
|
||||
{
|
||||
status: "modified",
|
||||
file,
|
||||
patch: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
||||
type: "update",
|
||||
filePath: file,
|
||||
relativePath: "src/demo-format.ts",
|
||||
diff: "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n",
|
||||
deletions: 1,
|
||||
},
|
||||
{
|
||||
status: "added",
|
||||
file: path.join(process.cwd(), "README-demo.md"),
|
||||
patch: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
|
||||
type: "add",
|
||||
filePath: path.join(process.cwd(), "README-demo.md"),
|
||||
relativePath: "README-demo.md",
|
||||
diff: "@@ -0,0 +1,4 @@\n+# Demo\n+This is a generated preview file.\n",
|
||||
deletions: 0,
|
||||
},
|
||||
],
|
||||
@@ -512,41 +509,46 @@ function emitPatch(state: State): void {
|
||||
}
|
||||
|
||||
function emitTask(state: State): void {
|
||||
const ref = make(state, "subagent", {
|
||||
const ref = make(state, "task", {
|
||||
description: "Scan run/* for reducer touchpoints",
|
||||
agent: "explore",
|
||||
subagent_type: "explore",
|
||||
})
|
||||
doneTool(state, ref, {
|
||||
title: "Reducer touchpoints found",
|
||||
output: "",
|
||||
metadata: {
|
||||
sessionID: "sub_demo_1",
|
||||
status: "completed",
|
||||
output: "",
|
||||
toolcalls: 4,
|
||||
sessionId: "sub_demo_1",
|
||||
},
|
||||
})
|
||||
const part = {
|
||||
id: "sub_demo_tool_1",
|
||||
type: "tool",
|
||||
id: "sub_demo_call_1",
|
||||
name: "read",
|
||||
sessionID: "sub_demo_1",
|
||||
messageID: "sub_demo_msg_tool",
|
||||
callID: "sub_demo_call_1",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "running",
|
||||
input: {
|
||||
path: "packages/tui/src/mini/stream.ts",
|
||||
filePath: "packages/cli/src/mini/stream.ts",
|
||||
offset: 1,
|
||||
limit: 200,
|
||||
},
|
||||
structured: {},
|
||||
content: [],
|
||||
time: {
|
||||
start: Date.now(),
|
||||
},
|
||||
},
|
||||
time: { created: Date.now(), ran: Date.now() },
|
||||
} satisfies SessionMessageAssistantTool
|
||||
} satisfies MiniToolPart
|
||||
showSubagent(state, {
|
||||
sessionID: "sub_demo_1",
|
||||
partID: ref.part,
|
||||
callID: ref.call,
|
||||
label: "Explore",
|
||||
description: "Scan run/* for reducer touchpoints",
|
||||
status: "completed",
|
||||
title: "Reducer touchpoints found",
|
||||
toolCalls: 4,
|
||||
commits: [
|
||||
{
|
||||
kind: "user",
|
||||
@@ -595,7 +597,6 @@ function emitQuestionTool(state: State): void {
|
||||
{ label: "Code", description: "Show code block" },
|
||||
],
|
||||
multiple: false,
|
||||
custom: false,
|
||||
},
|
||||
{
|
||||
header: "Extras",
|
||||
@@ -638,7 +639,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
title: "git status --short",
|
||||
output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
|
||||
metadata: {
|
||||
exit: 0,
|
||||
exitCode: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -648,7 +649,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
if (kind === "read") {
|
||||
const target = path.join(root, "package.json")
|
||||
const ref = make(state, "read", {
|
||||
path: target,
|
||||
filePath: target,
|
||||
offset: 1,
|
||||
limit: 80,
|
||||
})
|
||||
@@ -666,23 +667,22 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
return
|
||||
}
|
||||
|
||||
if (kind === "subagent") {
|
||||
const ref = make(state, "subagent", {
|
||||
if (kind === "task") {
|
||||
const ref = make(state, "task", {
|
||||
description: "Inspect footer spacing across direct-mode prompts",
|
||||
agent: "explore",
|
||||
subagent_type: "explore",
|
||||
})
|
||||
askPermission(state, {
|
||||
ref,
|
||||
permission: "subagent",
|
||||
permission: "task",
|
||||
patterns: ["explore"],
|
||||
always: ["*"],
|
||||
done: {
|
||||
title: "Footer spacing checked",
|
||||
output: "",
|
||||
metadata: {
|
||||
sessionID: "sub_demo_perm_1",
|
||||
status: "completed",
|
||||
output: "",
|
||||
toolcalls: 3,
|
||||
sessionId: "sub_demo_perm_1",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -693,7 +693,7 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
const dir = path.join(path.dirname(root), "demo-shared")
|
||||
const target = path.join(dir, "README.md")
|
||||
const ref = make(state, "read", {
|
||||
path: target,
|
||||
filePath: target,
|
||||
offset: 1,
|
||||
limit: 40,
|
||||
})
|
||||
@@ -716,9 +716,9 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
}
|
||||
|
||||
if (kind === "doom") {
|
||||
const ref = make(state, "subagent", {
|
||||
const ref = make(state, "task", {
|
||||
description: "Retry the formatter after repeated failures",
|
||||
agent: "general",
|
||||
subagent_type: "general",
|
||||
})
|
||||
askPermission(state, {
|
||||
ref,
|
||||
@@ -736,7 +736,9 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
|
||||
const diff = "@@ -1 +1 @@\n-export const demo = 1\n+export const demo = 42\n"
|
||||
const ref = make(state, "edit", {
|
||||
path: file,
|
||||
filePath: file,
|
||||
filepath: file,
|
||||
diff,
|
||||
})
|
||||
askPermission(state, {
|
||||
ref,
|
||||
@@ -747,98 +749,96 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
title: "edit",
|
||||
output: "",
|
||||
metadata: {
|
||||
files: [{ file, status: "modified", patch: diff }],
|
||||
diff,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function demoForm(kind: FormKind): { title: string; fields: MiniFormRequest["fields"]; questions?: JsonValue[] } {
|
||||
if (kind === "question") {
|
||||
const questions: JsonValue[] = [
|
||||
function emitQuestion(state: State, kind: QuestionKind = "multi"): void {
|
||||
const questions = (() => {
|
||||
if (kind === "single") {
|
||||
return [
|
||||
{
|
||||
header: "Mode",
|
||||
question: "Which footer should be the reference for spacing checks?",
|
||||
options: [
|
||||
{ label: "Permission", description: "Inspect the permission footer" },
|
||||
{ label: "Question", description: "Keep this question footer open" },
|
||||
{ label: "Prompt", description: "Return to the normal composer" },
|
||||
],
|
||||
multiple: false,
|
||||
custom: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (kind === "checklist") {
|
||||
return [
|
||||
{
|
||||
header: "Checks",
|
||||
question: "Select the direct-mode cases you want to inspect next",
|
||||
options: [
|
||||
{ label: "Diff", description: "Show an edit diff in the footer" },
|
||||
{ label: "Task", description: "Show a structured task summary" },
|
||||
{ label: "Error", description: "Show an error transcript row" },
|
||||
],
|
||||
multiple: true,
|
||||
custom: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
if (kind === "custom") {
|
||||
return [
|
||||
{
|
||||
header: "Reply",
|
||||
question: "What custom answer should appear in the footer preview?",
|
||||
options: [
|
||||
{ label: "Short note", description: "Keep the answer to one line" },
|
||||
{ label: "Wrapped note", description: "Use a longer answer to test wrapping" },
|
||||
],
|
||||
multiple: false,
|
||||
custom: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
header: "Layout",
|
||||
question: "Which footer view should be the reference for spacing checks?",
|
||||
question: "Which footer view should stay active while testing?",
|
||||
options: [
|
||||
{ label: "Form", description: "Inspect the canonical Form footer" },
|
||||
{ label: "Prompt", description: "Return to the normal composer" },
|
||||
{ label: "Prompt", description: "Return to prompt" },
|
||||
{ label: "Question", description: "Keep question open" },
|
||||
],
|
||||
multiple: false,
|
||||
custom: true,
|
||||
},
|
||||
{
|
||||
header: "Checks",
|
||||
header: "Rows",
|
||||
question: "Pick formatting previews",
|
||||
options: [
|
||||
{ label: "Diff", description: "Emit an edit diff" },
|
||||
{ label: "Subagent", description: "Emit a subagent card" },
|
||||
{ label: "Diff", description: "Emit edit diff" },
|
||||
{ label: "Task", description: "Emit task card" },
|
||||
],
|
||||
multiple: true,
|
||||
custom: true,
|
||||
},
|
||||
]
|
||||
return {
|
||||
title: "Questions",
|
||||
questions,
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Layout",
|
||||
description: "Which footer view should be the reference for spacing checks?",
|
||||
type: "string",
|
||||
options: [
|
||||
{ value: "Form", label: "Form", description: "Inspect the canonical Form footer" },
|
||||
{ value: "Prompt", label: "Prompt", description: "Return to the normal composer" },
|
||||
],
|
||||
custom: true,
|
||||
},
|
||||
{
|
||||
key: "q1",
|
||||
title: "Checks",
|
||||
description: "Pick formatting previews",
|
||||
type: "multiselect",
|
||||
options: [
|
||||
{ value: "Diff", label: "Diff", description: "Emit an edit diff" },
|
||||
{ value: "Subagent", label: "Subagent", description: "Emit a subagent card" },
|
||||
],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: "MCP authorization",
|
||||
fields: [
|
||||
{
|
||||
key: "authorization",
|
||||
type: "external",
|
||||
url: "https://example.com/opencode-demo",
|
||||
title: "Authorize demo MCP server",
|
||||
description: "Complete authorization in your browser",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
function emitForm(state: State, kind: FormKind = "question"): void {
|
||||
const form = demoForm(kind)
|
||||
const ref = make(state, kind === "question" ? "question" : "mcp_demo", {
|
||||
...(form.questions ? { questions: form.questions } : { form: kind }),
|
||||
})
|
||||
const ref = make(state, "question", { questions })
|
||||
startTool(state, ref)
|
||||
state.form++
|
||||
const request: MiniFormRequest = {
|
||||
id: `frm_demo_${state.form}`,
|
||||
|
||||
const id = take(state, "ask", "ask")
|
||||
state.asks.set(id, { ref })
|
||||
|
||||
present(state, [], {
|
||||
id,
|
||||
sessionID: state.id,
|
||||
title: form.title,
|
||||
metadata:
|
||||
kind === "question"
|
||||
? { kind: "question", tool: { messageID: ref.msg, callID: ref.call } }
|
||||
: { kind: "mcp", message: `Synthetic ${kind} MCP elicitation` },
|
||||
fields: form.fields,
|
||||
}
|
||||
state.forms.set(request.id, { ref, kind, request })
|
||||
present(state, [], { type: "form", request })
|
||||
questions,
|
||||
tool: { messageID: ref.msg, callID: ref.call },
|
||||
})
|
||||
}
|
||||
|
||||
async function emitFmt(state: State, kind: string, body: string, signal?: AbortSignal): Promise<boolean> {
|
||||
@@ -882,7 +882,7 @@ async function emitFmt(state: State, kind: string, body: string, signal?: AbortS
|
||||
return true
|
||||
}
|
||||
|
||||
if (kind === "subagent") {
|
||||
if (kind === "task") {
|
||||
emitTask(state)
|
||||
return true
|
||||
}
|
||||
@@ -921,12 +921,11 @@ function intro(state: State): void {
|
||||
[
|
||||
"Demo slash commands enabled for interactive mode.",
|
||||
`- /permission [kind] (${PERMISSIONS.join(", ")})`,
|
||||
`- /form [kind] (${FORMS.join(", ")})`,
|
||||
`- /question [kind] (${QUESTIONS.join(", ")})`,
|
||||
`- /fmt <kind> (${KINDS.join(", ")})`,
|
||||
"Examples:",
|
||||
"- /permission shell",
|
||||
"- /form question",
|
||||
"- /form external",
|
||||
"- /question custom",
|
||||
"- /fmt markdown",
|
||||
"- /fmt table",
|
||||
"- /fmt text your custom text",
|
||||
@@ -943,9 +942,9 @@ export function createRunDemo(input: Input) {
|
||||
part: 0,
|
||||
call: 0,
|
||||
perm: 0,
|
||||
form: 0,
|
||||
ask: 0,
|
||||
perms: new Map(),
|
||||
forms: new Map(),
|
||||
asks: new Map(),
|
||||
started: new Set(),
|
||||
}
|
||||
|
||||
@@ -976,14 +975,14 @@ export function createRunDemo(input: Input) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (cmd === "/form") {
|
||||
const kind = formKind(list[1])
|
||||
if (cmd === "/question") {
|
||||
const kind = questionKind(list[1])
|
||||
if (!kind) {
|
||||
note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`)
|
||||
note(state.footer, `Pick a question kind: ${QUESTIONS.join(", ")}`)
|
||||
return true
|
||||
}
|
||||
|
||||
emitForm(state, kind)
|
||||
emitQuestion(state, kind)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1025,41 +1024,33 @@ export function createRunDemo(input: Input) {
|
||||
return true
|
||||
}
|
||||
|
||||
const formReply = (input: FormReply): boolean => {
|
||||
const form = state.forms.get(input.formID)
|
||||
if (!form || input.sessionID !== form.request.sessionID) return false
|
||||
state.forms.delete(input.formID)
|
||||
clearBlocker(state)
|
||||
if (form.kind === "question") {
|
||||
doneTool(state, form.ref, {
|
||||
title: "question",
|
||||
output: "",
|
||||
metadata: {
|
||||
answers: form.request.fields.map((field) => {
|
||||
const value = input.answer[field.key]
|
||||
if (value === undefined) return []
|
||||
return Array.isArray(value) ? [...value] : [String(value)]
|
||||
}),
|
||||
},
|
||||
})
|
||||
return true
|
||||
const questionReply = (input: QuestionReply): boolean => {
|
||||
const ask = state.asks.get(input.requestID)
|
||||
if (!ask || !input.answers) {
|
||||
return false
|
||||
}
|
||||
doneTool(state, form.ref, {
|
||||
title: form.request.title,
|
||||
output: `Form submitted: ${Object.entries(input.answer)
|
||||
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(", ") : String(value)}`)
|
||||
.join("; ")}\n`,
|
||||
metadata: { answer: input.answer },
|
||||
|
||||
state.asks.delete(input.requestID)
|
||||
clearBlocker(state)
|
||||
doneTool(state, ask.ref, {
|
||||
title: "question",
|
||||
output: "",
|
||||
metadata: {
|
||||
answers: input.answers,
|
||||
},
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const formCancel = (input: FormCancel): boolean => {
|
||||
const form = state.forms.get(input.formID)
|
||||
if (!form || input.sessionID !== form.request.sessionID) return false
|
||||
state.forms.delete(input.formID)
|
||||
const questionReject = (input: QuestionReject): boolean => {
|
||||
const ask = state.asks.get(input.requestID)
|
||||
if (!ask) {
|
||||
return false
|
||||
}
|
||||
|
||||
state.asks.delete(input.requestID)
|
||||
clearBlocker(state)
|
||||
failTool(state, form.ref, "form cancelled")
|
||||
failTool(state, ask.ref, "question rejected")
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1067,7 +1058,7 @@ export function createRunDemo(input: Input) {
|
||||
start,
|
||||
prompt,
|
||||
permission,
|
||||
formReply,
|
||||
formCancel,
|
||||
questionReply,
|
||||
questionReject,
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,11 @@ export type EntryFlags = {
|
||||
trailingNewline: boolean
|
||||
}
|
||||
|
||||
const RUN_ENTRY_NONE: RunEntryBody = {
|
||||
export const RUN_ENTRY_NONE: RunEntryBody = {
|
||||
type: "none",
|
||||
}
|
||||
|
||||
function cleanRunText(text: string): string {
|
||||
export function cleanRunText(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n")
|
||||
}
|
||||
|
||||
+338
-218
@@ -48,6 +48,8 @@ type QueuedEntry = PanelEntry & {
|
||||
prompt: FooterQueuedPrompt
|
||||
}
|
||||
|
||||
type MenuState = ReturnType<typeof createFooterMenuState>
|
||||
|
||||
const PANEL_PAD = 2
|
||||
const PANEL_LIST_ROWS = 10
|
||||
const PANEL_FRAME_ROWS = 6
|
||||
@@ -122,6 +124,72 @@ function subagentStatusLabel(status: FooterSubagentTab["status"]) {
|
||||
return "running"
|
||||
}
|
||||
|
||||
function handleKey(input: {
|
||||
event: KeyEvent
|
||||
menu: MenuState
|
||||
field: () => InputRenderable | undefined
|
||||
setQuery: (value: string) => void
|
||||
select: () => void
|
||||
close: () => void
|
||||
}) {
|
||||
const name = input.event.name.toLowerCase()
|
||||
const ctrl = input.event.ctrl && !input.event.meta && !input.event.shift && !input.event.super
|
||||
|
||||
if (name === "escape" || (ctrl && name === "c")) {
|
||||
input.event.preventDefault()
|
||||
input.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "up" || (ctrl && name === "p")) {
|
||||
input.event.preventDefault()
|
||||
input.menu.move(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "down" || (ctrl && name === "n")) {
|
||||
input.event.preventDefault()
|
||||
input.menu.move(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pageup") {
|
||||
input.event.preventDefault()
|
||||
input.menu.reveal(input.menu.selected() - PANEL_PAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pagedown") {
|
||||
input.event.preventDefault()
|
||||
input.menu.reveal(input.menu.selected() + PANEL_PAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "home") {
|
||||
input.event.preventDefault()
|
||||
input.menu.reveal(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "end") {
|
||||
input.event.preventDefault()
|
||||
input.menu.reveal(Number.POSITIVE_INFINITY)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "return") {
|
||||
input.event.preventDefault()
|
||||
input.select()
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl && name === "u") {
|
||||
input.event.preventDefault()
|
||||
input.setQuery("")
|
||||
input.field()?.setText("")
|
||||
}
|
||||
}
|
||||
|
||||
function match<T extends PanelEntry>(query: string, entries: T[]) {
|
||||
const text = query.trim()
|
||||
if (!text) {
|
||||
@@ -133,128 +201,6 @@ function match<T extends PanelEntry>(query: string, entries: T[]) {
|
||||
.map((item) => item.obj)
|
||||
}
|
||||
|
||||
function createSearchablePanelController<T extends PanelEntry>(input: {
|
||||
entries: Accessor<T[]>
|
||||
limit: number
|
||||
onClose: () => void
|
||||
onSelect: (item: T) => void
|
||||
isCurrent?: (item: T) => boolean
|
||||
closeOnFirstUp?: boolean
|
||||
onKey?: (event: KeyEvent, item: T | undefined) => boolean
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const items = createMemo<T[]>(() => match(query(), input.entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: input.limit })
|
||||
const selected = () => items()[menu.selected()]
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!input.isCurrent || query().trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items().findIndex(input.isCurrent)
|
||||
if (index !== -1) {
|
||||
menu.reveal(index)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
input.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
if (input.onKey?.(event, selected())) {
|
||||
return
|
||||
}
|
||||
|
||||
const name = event.name.toLowerCase()
|
||||
if (input.closeOnFirstUp && name === "up" && menu.selected() === 0) {
|
||||
event.preventDefault()
|
||||
input.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
|
||||
if (name === "escape" || (ctrl && name === "c")) {
|
||||
event.preventDefault()
|
||||
input.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "up" || (ctrl && name === "p")) {
|
||||
event.preventDefault()
|
||||
menu.move(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "down" || (ctrl && name === "n")) {
|
||||
event.preventDefault()
|
||||
menu.move(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pageup") {
|
||||
event.preventDefault()
|
||||
menu.reveal(menu.selected() - PANEL_PAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "pagedown") {
|
||||
event.preventDefault()
|
||||
menu.reveal(menu.selected() + PANEL_PAGE)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "home") {
|
||||
event.preventDefault()
|
||||
menu.reveal(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "end") {
|
||||
event.preventDefault()
|
||||
menu.reveal(Number.POSITIVE_INFINITY)
|
||||
return
|
||||
}
|
||||
|
||||
if (name === "return") {
|
||||
event.preventDefault()
|
||||
const item = selected()
|
||||
if (item) {
|
||||
input.onSelect(item)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (ctrl && name === "u") {
|
||||
event.preventDefault()
|
||||
setQuery("")
|
||||
field?.setText("")
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
items,
|
||||
menu,
|
||||
inputRef(input: InputRenderable) {
|
||||
field = input
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function PanelShell(props: {
|
||||
title: string
|
||||
countVisible?: boolean
|
||||
@@ -404,6 +350,8 @@ export function RunCommandMenuBody(props: {
|
||||
onNew: () => void
|
||||
onExit: () => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
||||
const entries = createMemo<CommandEntry[]>(() => {
|
||||
@@ -518,6 +466,8 @@ export function RunCommandMenuBody(props: {
|
||||
{ action: "exit", category: "System", display: "Exit", footer: "/exit", keywords: "/exit exit" },
|
||||
]
|
||||
})
|
||||
const items = createMemo<CommandEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||
const pick = (item: CommandEntry) => {
|
||||
if (item.action === "model") {
|
||||
props.onModel()
|
||||
@@ -566,39 +516,56 @@ export function RunCommandMenuBody(props: {
|
||||
|
||||
props.onCommand(item.name)
|
||||
}
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: pick,
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
pick(item)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Commands"
|
||||
countVisible={false}
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
query={query()}
|
||||
count={items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty="No results found"
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
grouped={!controller.query().trim()}
|
||||
grouped={!query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
/>
|
||||
@@ -614,6 +581,8 @@ export function RunSubagentSelectBody(props: {
|
||||
onSelect: (sessionID: string) => void
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<SubagentEntry[]>(() =>
|
||||
props.tabs().map((item) => {
|
||||
const title = item.description || item.title || item.label
|
||||
@@ -628,35 +597,72 @@ export function RunSubagentSelectBody(props: {
|
||||
}
|
||||
}),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: SUBAGENT_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSelect(item.sessionID),
|
||||
isCurrent: (item) => item.current,
|
||||
closeOnFirstUp: true,
|
||||
onRows: props.onRows,
|
||||
const items = createMemo<SubagentEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
props.onSelect(item.sessionID)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (query().trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items().findIndex((item) => item.current)
|
||||
if (index !== -1) {
|
||||
menu.reveal(index)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name.toLowerCase() === "up" && menu.selected() === 0) {
|
||||
event.preventDefault()
|
||||
props.onClose()
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select subagent"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
query={query()}
|
||||
count={items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No subagents found"
|
||||
border={false}
|
||||
@@ -677,6 +683,8 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
onDelete: (prompt: FooterQueuedPrompt) => void | Promise<void>
|
||||
onRows?: (rows: number) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<QueuedEntry[]>(() =>
|
||||
props.prompts().map((prompt) => ({
|
||||
category: "",
|
||||
@@ -686,49 +694,72 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
prompt,
|
||||
})),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: SUBAGENT_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onEdit(item.prompt),
|
||||
onRows: props.onRows,
|
||||
onKey: (event, item) => {
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
|
||||
if (item && (event.name === "delete" || (ctrl && event.name === "d"))) {
|
||||
event.preventDefault()
|
||||
props.onDelete(item.prompt)
|
||||
return true
|
||||
}
|
||||
const items = createMemo<QueuedEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: SUBAGENT_LIST_ROWS })
|
||||
const selected = () => items()[menu.selected()]
|
||||
|
||||
if (item && ctrl && event.name === "e") {
|
||||
event.preventDefault()
|
||||
props.onEdit(item.prompt)
|
||||
return true
|
||||
}
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
return false
|
||||
},
|
||||
createEffect(() => {
|
||||
props.onRows?.(menu.rows() + PANEL_FRAME_ROWS)
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = selected()
|
||||
const ctrl = event.ctrl && !event.meta && !event.shift && !event.super
|
||||
if (item && (event.name === "delete" || (ctrl && event.name === "d"))) {
|
||||
event.preventDefault()
|
||||
props.onDelete(item.prompt)
|
||||
return
|
||||
}
|
||||
|
||||
if (item && ctrl && event.name === "e") {
|
||||
event.preventDefault()
|
||||
props.onEdit(item.prompt)
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({
|
||||
event,
|
||||
menu,
|
||||
field: () => field,
|
||||
setQuery,
|
||||
select: () => {
|
||||
const item = selected()
|
||||
if (item) props.onEdit(item.prompt)
|
||||
},
|
||||
close: props.onClose,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Queued prompts"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
query={query()}
|
||||
count={items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
rows={controller.menu.rows}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={menu.rows}
|
||||
limit={SUBAGENT_LIST_ROWS}
|
||||
empty="No queued prompts"
|
||||
border={false}
|
||||
@@ -747,6 +778,8 @@ export function RunSkillSelectBody(props: {
|
||||
onClose: () => void
|
||||
onSelect: (name: string) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<SkillEntry[]>(() =>
|
||||
(props.commands() ?? [])
|
||||
.filter((item) => item.source === "skill")
|
||||
@@ -759,31 +792,50 @@ export function RunSkillSelectBody(props: {
|
||||
}))
|
||||
.sort((a, b) => a.display.localeCompare(b.display)),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSelect(item.name),
|
||||
const items = createMemo<SkillEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
props.onSelect(item.name)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Skills"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
query={query()}
|
||||
count={items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty={props.commands() ? "No skills found" : "Skills loading"}
|
||||
@@ -804,6 +856,8 @@ export function RunVariantSelectBody(props: {
|
||||
onClose: () => void
|
||||
onSelect: (variant: string | undefined) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<VariantEntry[]>(() => [
|
||||
{
|
||||
category: "",
|
||||
@@ -822,32 +876,64 @@ export function RunVariantSelectBody(props: {
|
||||
current: props.current() === variant,
|
||||
})),
|
||||
])
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSelect(item.variant),
|
||||
isCurrent: (item) => item.current,
|
||||
const items = createMemo<VariantEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||
const pick = (item: VariantEntry) => {
|
||||
props.onSelect(item.variant)
|
||||
}
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
pick(item)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (query().trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items().findIndex((item) => item.current)
|
||||
if (index !== -1) {
|
||||
menu.reveal(index)
|
||||
}
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select variant"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
query={query()}
|
||||
count={items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty="No results found"
|
||||
@@ -868,6 +954,8 @@ export function RunModelSelectBody(props: {
|
||||
onClose: () => void
|
||||
onSelect: (model: NonNullable<RunInput["model"]>) => void
|
||||
}) {
|
||||
let field: InputRenderable | undefined
|
||||
const [query, setQuery] = createSignal("")
|
||||
const entries = createMemo<ModelEntry[]>(() =>
|
||||
(props.providers() ?? [])
|
||||
.flatMap((provider) =>
|
||||
@@ -909,39 +997,71 @@ export function RunModelSelectBody(props: {
|
||||
return a.display.localeCompare(b.display)
|
||||
}),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
entries,
|
||||
limit: PANEL_LIST_ROWS,
|
||||
onClose: props.onClose,
|
||||
onSelect: (item) => props.onSelect({ providerID: item.providerID, modelID: item.modelID }),
|
||||
isCurrent: (item) => item.current,
|
||||
const items = createMemo<ModelEntry[]>(() => match(query(), entries()))
|
||||
const menu = createFooterMenuState({ count: () => items().length, limit: PANEL_LIST_ROWS })
|
||||
const pick = (item: ModelEntry) => {
|
||||
props.onSelect({ providerID: item.providerID, modelID: item.modelID })
|
||||
}
|
||||
const select = () => {
|
||||
const item = items()[menu.selected()]
|
||||
if (!item) {
|
||||
return
|
||||
}
|
||||
|
||||
pick(item)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
query()
|
||||
menu.reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (query().trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = items().findIndex((item) => item.current)
|
||||
if (index !== -1) {
|
||||
menu.reveal(index)
|
||||
}
|
||||
})
|
||||
|
||||
useKeyboard((event) => {
|
||||
if (event.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
handleKey({ event, menu, field: () => field, setQuery, select, close: props.onClose })
|
||||
})
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
title="Select model"
|
||||
query={controller.query()}
|
||||
count={controller.items().length}
|
||||
query={query()}
|
||||
count={items().length}
|
||||
total={entries().length}
|
||||
placeholder="Search"
|
||||
theme={props.theme}
|
||||
inputRef={controller.inputRef}
|
||||
onQuery={controller.setQuery}
|
||||
inputRef={(input) => {
|
||||
field = input
|
||||
}}
|
||||
onQuery={setQuery}
|
||||
dark
|
||||
chrome="minimal"
|
||||
>
|
||||
<RunFooterMenu
|
||||
theme={props.theme}
|
||||
items={controller.items}
|
||||
selected={controller.menu.selected}
|
||||
offset={controller.menu.offset}
|
||||
items={items}
|
||||
selected={menu.selected}
|
||||
offset={menu.offset}
|
||||
rows={() => PANEL_LIST_ROWS}
|
||||
limit={PANEL_LIST_ROWS}
|
||||
empty={props.providers() ? "No results found" : "Models loading"}
|
||||
border={false}
|
||||
paddingLeft={PANEL_PAD}
|
||||
paddingRight={PANEL_PAD}
|
||||
grouped={!controller.query().trim()}
|
||||
grouped={!query().trim()}
|
||||
background
|
||||
headerColor={props.theme().muted}
|
||||
/>
|
||||
@@ -3,9 +3,8 @@ import { TextAttributes, type ColorInput } from "@opentui/core"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createSignal, type Accessor } from "solid-js"
|
||||
import { transparent, type RunFooterTheme } from "./theme"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { moveSelection, moveSelectionOffset, reconcileSelection, revealSelectionOffset } from "../ui/select-controller"
|
||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||
import { stringWidth } from "@opencode-ai/tui/util/string-width"
|
||||
|
||||
export const FOOTER_MENU_ROWS = 8
|
||||
|
||||
@@ -21,6 +20,41 @@ type RunFooterMenuRow =
|
||||
| { type: "item"; item: RunFooterMenuItem; index: number }
|
||||
| { type: "spacer" }
|
||||
|
||||
function maxOffset(count: number, limit: number) {
|
||||
return Math.max(0, count - limit)
|
||||
}
|
||||
|
||||
function previewMargin(limit: number) {
|
||||
return Math.max(0, Math.min(2, Math.floor((limit - 1) / 2)))
|
||||
}
|
||||
|
||||
function revealOffset(value: number, input: { count: number; limit: number; selected: number }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
if (input.selected < value) {
|
||||
return Math.min(max, input.selected)
|
||||
}
|
||||
|
||||
if (input.selected >= value + input.limit) {
|
||||
return Math.min(max, input.selected - input.limit + 1)
|
||||
}
|
||||
|
||||
return Math.min(max, value)
|
||||
}
|
||||
|
||||
function moveOffset(value: number, input: { count: number; limit: number; selected: number; dir: -1 | 1 }) {
|
||||
const max = maxOffset(input.count, input.limit)
|
||||
const margin = previewMargin(input.limit)
|
||||
if (input.dir < 0 && input.selected < value + margin) {
|
||||
return Math.max(0, Math.min(max, input.selected - margin))
|
||||
}
|
||||
|
||||
if (input.dir > 0 && input.selected > value + input.limit - margin - 1) {
|
||||
return Math.min(max, input.selected - input.limit + margin + 1)
|
||||
}
|
||||
|
||||
return Math.min(max, value)
|
||||
}
|
||||
|
||||
export function createFooterMenuState(input: { count: Accessor<number>; limit?: number }) {
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const [offset, setOffset] = createSignal(0)
|
||||
@@ -29,9 +63,15 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
|
||||
|
||||
const reveal = (index: number) => {
|
||||
const count = input.count()
|
||||
const next = reconcileSelection(index, count)
|
||||
if (count === 0) {
|
||||
setSelected(0)
|
||||
setOffset(0)
|
||||
return
|
||||
}
|
||||
|
||||
const next = Math.max(0, Math.min(count - 1, index))
|
||||
setSelected(next)
|
||||
setOffset((value) => revealSelectionOffset(value, { count, limit: limit(), selected: next }))
|
||||
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: next }))
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
@@ -41,16 +81,28 @@ export function createFooterMenuState(input: { count: Accessor<number>; limit?:
|
||||
|
||||
createEffect(() => {
|
||||
const count = input.count()
|
||||
const next = reconcileSelection(selected(), count)
|
||||
setSelected(next)
|
||||
setOffset((value) => revealSelectionOffset(value, { count, limit: limit(), selected: next }))
|
||||
if (count === 0) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
if (selected() >= count) {
|
||||
setSelected(count - 1)
|
||||
}
|
||||
|
||||
setOffset((value) => revealOffset(value, { count, limit: limit(), selected: selected() }))
|
||||
})
|
||||
|
||||
const move = (dir: -1 | 1) => {
|
||||
const count = input.count()
|
||||
const next = moveSelection(selected(), { count, delta: dir, policy: "clamp" })
|
||||
if (count === 0) {
|
||||
reset()
|
||||
return
|
||||
}
|
||||
|
||||
const next = Math.max(0, Math.min(count - 1, selected() + dir))
|
||||
setSelected(next)
|
||||
setOffset((value) => moveSelectionOffset(value, { count, limit: limit(), selected: next, direction: dir }))
|
||||
setOffset((value) => moveOffset(value, { count, limit: limit(), selected: next, dir }))
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -117,8 +169,8 @@ export function RunFooterMenu(props: {
|
||||
const dir = props.selected() === previous + 1 ? 1 : props.selected() === previous - 1 ? -1 : undefined
|
||||
setGroupOffset((value) =>
|
||||
dir
|
||||
? moveSelectionOffset(value, { count: all.length, limit: limit(), selected, direction: dir })
|
||||
: revealSelectionOffset(value, { count: all.length, limit: limit(), selected }),
|
||||
? moveOffset(value, { count: all.length, limit: limit(), selected, dir })
|
||||
: revealOffset(value, { count: all.length, limit: limit(), selected }),
|
||||
)
|
||||
previous = props.selected()
|
||||
})
|
||||
+17
-43
@@ -14,6 +14,7 @@
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Match, Show, Switch, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { PermissionV2Request } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
createPermissionBodyState,
|
||||
permissionAlwaysLines,
|
||||
@@ -31,7 +32,7 @@ import {
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import { toolFiletype } from "./tool"
|
||||
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
|
||||
import type { MiniPermissionRequest, PermissionReply } from "./types"
|
||||
import type { PermissionReply, RunDiffStyle } from "./types"
|
||||
|
||||
function buttons(
|
||||
list: PermissionOption[],
|
||||
@@ -129,15 +130,15 @@ export function RejectField(props: {
|
||||
}
|
||||
|
||||
export function RunPermissionBody(props: {
|
||||
request: MiniPermissionRequest
|
||||
directory?: () => string
|
||||
request: PermissionV2Request
|
||||
theme: RunFooterTheme
|
||||
block: RunBlockTheme
|
||||
diffStyle?: RunDiffStyle
|
||||
onReply: (input: PermissionReply) => void | Promise<void>
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [state, setState] = createSignal(createPermissionBodyState(props.request))
|
||||
const info = createMemo(() => permissionInfo(props.request, props.directory?.()))
|
||||
const [state, setState] = createSignal(createPermissionBodyState(props.request.id))
|
||||
const info = createMemo(() => permissionInfo(props.request))
|
||||
const ft = createMemo(() => toolFiletype(info().file))
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
const opts = createMemo(() =>
|
||||
@@ -162,7 +163,7 @@ export function RunPermissionBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
setState(createPermissionBodyState(props.request))
|
||||
setState(createPermissionBodyState(id))
|
||||
})
|
||||
|
||||
const shift = (dir: -1 | 1) => {
|
||||
@@ -360,42 +361,15 @@ export function RunPermissionBody(props: {
|
||||
<Show
|
||||
when={info().diff}
|
||||
fallback={
|
||||
<Show
|
||||
when={info().patch}
|
||||
fallback={
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<For each={info().lines}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
{(patch) => (
|
||||
<Show
|
||||
when={props.block.syntax}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
{patch()}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
{(syntax) => (
|
||||
<code
|
||||
filetype="diff"
|
||||
drawUnstyledText={false}
|
||||
streaming={true}
|
||||
syntaxStyle={syntax()}
|
||||
content={patch()}
|
||||
fg={props.theme.muted}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
<box width="100%" flexDirection="column" gap={1} paddingLeft={1}>
|
||||
<For each={info().lines}>
|
||||
{(line) => (
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{line}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<diff
|
||||
@@ -418,7 +392,7 @@ export function RunPermissionBody(props: {
|
||||
removedLineNumberBg={props.block.diffRemovedLineNumberBg}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={!info().diff && !info().patch && info().lines.length === 0}>
|
||||
<Show when={!info().diff && info().lines.length === 0}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.muted}>No diff provided</text>
|
||||
</box>
|
||||
@@ -7,13 +7,13 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { StyledText, fg, type ColorInput, type KeyEvent, type TextareaRenderable } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { normalizePromptContent } from "../prompt/content"
|
||||
import { normalizePromptContent } from "@opencode-ai/tui/prompt/content"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { Locale } from "@opencode-ai/tui/util/locale"
|
||||
import { stringWidth } from "@opencode-ai/tui/util/string-width"
|
||||
import {
|
||||
createPromptHistory,
|
||||
displayCharAt,
|
||||
@@ -22,11 +22,9 @@ import {
|
||||
mentionTriggerIndex,
|
||||
isNewCommand,
|
||||
movePromptHistory,
|
||||
promptCopy,
|
||||
pushPromptHistory,
|
||||
} from "./prompt.shared"
|
||||
import { parseFileLineRange, parseSlashHead, stripFileLineRange } from "../prompt/parse"
|
||||
import { Keymap } from "../context/keymap"
|
||||
import { Keymap } from "@opencode-ai/tui/context/keymap"
|
||||
import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.editor"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
@@ -59,7 +57,7 @@ type PromptOption = Auto | SlashOption
|
||||
type MenuMode = false | "mention" | "slash"
|
||||
|
||||
type PromptInput = {
|
||||
directory: Accessor<string>
|
||||
directory: string
|
||||
findFiles: (query: string) => Promise<string[]>
|
||||
agents: Accessor<RunAgent[]>
|
||||
references: Accessor<RunReference[]>
|
||||
@@ -97,6 +95,7 @@ export type PromptState = {
|
||||
openEditor: (input?: { value?: string }) => Promise<void>
|
||||
onKeyDown: (event: KeyEvent) => void
|
||||
onContentChange: () => void
|
||||
replaceDraft: (text: string) => void
|
||||
replacePrompt: (prompt: RunPrompt) => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}
|
||||
@@ -105,12 +104,61 @@ function clamp(rows: number): number {
|
||||
return Math.max(TEXTAREA_MIN_ROWS, Math.min(TEXTAREA_MAX_ROWS, rows))
|
||||
}
|
||||
|
||||
function clonePrompt(prompt: RunPrompt): RunPrompt {
|
||||
return {
|
||||
text: prompt.text,
|
||||
parts: structuredClone(prompt.parts),
|
||||
...(prompt.mode ? { mode: prompt.mode } : {}),
|
||||
...(prompt.command ? { command: prompt.command } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function emptyPrompt(shell: boolean): RunPrompt {
|
||||
return shell ? { text: "", parts: [], mode: "shell" } : { text: "", parts: [] }
|
||||
}
|
||||
|
||||
function removeLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
return hash === -1 ? input : input.slice(0, hash)
|
||||
}
|
||||
|
||||
function extractLineRange(input: string) {
|
||||
const hash = input.lastIndexOf("#")
|
||||
if (hash === -1) {
|
||||
return { base: input }
|
||||
}
|
||||
|
||||
const base = input.slice(0, hash)
|
||||
const line = input.slice(hash + 1)
|
||||
const match = line.match(/^(\d+)(?:-(\d*))?$/)
|
||||
if (!match) {
|
||||
return { base }
|
||||
}
|
||||
|
||||
const start = Number(match[1])
|
||||
const end = match[2] && start < Number(match[2]) ? Number(match[2]) : undefined
|
||||
return { base, line: { start, end } }
|
||||
}
|
||||
|
||||
function slashHead(text: string) {
|
||||
if (!text.startsWith("/")) {
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 1; i < text.length; i++) {
|
||||
switch (text[i]) {
|
||||
case " ":
|
||||
case "\t":
|
||||
case "\n":
|
||||
return { name: text.slice(1, i), arguments: text.slice(i + 1), end: i }
|
||||
}
|
||||
}
|
||||
|
||||
return { name: text.slice(1), arguments: "", end: text.length }
|
||||
}
|
||||
|
||||
function slashQuery(text: string, cursor: number) {
|
||||
const head = parseSlashHead(text.slice(0, cursor))
|
||||
const head = slashHead(text.slice(0, cursor))
|
||||
if (!head || head.end !== cursor) {
|
||||
return
|
||||
}
|
||||
@@ -119,7 +167,7 @@ function slashQuery(text: string, cursor: number) {
|
||||
}
|
||||
|
||||
function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
|
||||
const head = parseSlashHead(text)
|
||||
const head = slashHead(text)
|
||||
if (!head || head.name.length === 0) {
|
||||
return { type: "none" as const }
|
||||
}
|
||||
@@ -144,7 +192,7 @@ export function selectedCommand(text: string, command: RunPrompt["command"], com
|
||||
return
|
||||
}
|
||||
|
||||
const head = parseSlashHead(text)
|
||||
const head = slashHead(text)
|
||||
if (!head || head.name !== command.name) {
|
||||
return
|
||||
}
|
||||
@@ -328,16 +376,16 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return []
|
||||
}
|
||||
|
||||
const next = parseFileLineRange(value)
|
||||
const next = extractLineRange(value)
|
||||
const list = await input.findFiles(next.base)
|
||||
return list.map((item): Auto => {
|
||||
const url = pathToFileURL(path.resolve(input.directory(), item))
|
||||
const url = pathToFileURL(path.resolve(input.directory, item))
|
||||
let filename = item
|
||||
if (next.lineRange && !item.endsWith("/")) {
|
||||
filename = `${item}#${next.lineRange.startLine}${next.lineRange.endLine ? `-${next.lineRange.endLine}` : ""}`
|
||||
url.searchParams.set("start", String(next.lineRange.startLine))
|
||||
if (next.lineRange.endLine !== undefined) {
|
||||
url.searchParams.set("end", String(next.lineRange.endLine))
|
||||
if (next.line && !item.endsWith("/")) {
|
||||
filename = `${item}#${next.line.start}${next.line.end ? `-${next.line.end}` : ""}`
|
||||
url.searchParams.set("start", String(next.line.start))
|
||||
if (next.line.end !== undefined) {
|
||||
url.searchParams.set("end", String(next.line.end))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,7 +469,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return mixed
|
||||
}
|
||||
|
||||
const next = stripFileLineRange(query())
|
||||
const next = removeLineRange(query())
|
||||
if (mode() === "mention") {
|
||||
return [
|
||||
...fuzzysort.go(next, agents(), { keys: ["value", "display", "description"] }).map((item) => item.obj),
|
||||
@@ -556,7 +604,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const restore = (value: RunPrompt, cursor = stringWidth(value.text)) => {
|
||||
draft = promptCopy(value)
|
||||
draft = clonePrompt(value)
|
||||
setShell(value.mode === "shell")
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
@@ -586,6 +634,21 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
area.focus()
|
||||
}
|
||||
|
||||
const replaceDraft = (text: string) => {
|
||||
draft = shell() ? { text, parts: [], mode: "shell" } : { text, parts: [] }
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
hide()
|
||||
area.setText(text)
|
||||
clearParts()
|
||||
draft = shell() ? { text: area.plainText, parts: [], mode: "shell" } : { text: area.plainText, parts: [] }
|
||||
area.cursorOffset = Math.min(stringWidth(text), stringWidth(area.plainText))
|
||||
scheduleRows()
|
||||
area.focus()
|
||||
}
|
||||
|
||||
const refresh = () => {
|
||||
if (!area || area.isDestroyed) {
|
||||
return
|
||||
@@ -695,7 +758,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
if (history.index === null && dir === -1) {
|
||||
stash = promptCopy(draft)
|
||||
stash = clonePrompt(draft)
|
||||
}
|
||||
|
||||
const next = movePromptHistory(history, dir, area.plainText, area.cursorOffset)
|
||||
@@ -773,7 +836,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
syncDraft()
|
||||
hide()
|
||||
|
||||
const current = promptCopy(draft)
|
||||
const current = clonePrompt(draft)
|
||||
try {
|
||||
const content = await input.onEditorOpen({
|
||||
value: inputValue?.value ?? current.text,
|
||||
@@ -816,7 +879,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
}
|
||||
|
||||
const cursor = area.cursorOffset
|
||||
const head = parseSlashHead(area.plainText)
|
||||
const head = slashHead(area.plainText)
|
||||
const local = !shell() && (next.name === "new" || next.name === "exit")
|
||||
const separator = !shell() && !local && head && /\s/.test(area.plainText[head.end] ?? "") ? "" : " "
|
||||
const text = `/${next.name}${separator}`
|
||||
@@ -833,7 +896,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
hide()
|
||||
syncDraft()
|
||||
if (!shell()) {
|
||||
submitPrompt(promptCopy(draft))
|
||||
submitPrompt(clonePrompt(draft))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1096,7 +1159,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
const submitPrompt = (next: RunPrompt) => {
|
||||
if (!area || area.isDestroyed) {
|
||||
draft = promptCopy(next)
|
||||
draft = clonePrompt(next)
|
||||
}
|
||||
|
||||
if (visible()) {
|
||||
@@ -1152,7 +1215,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
|
||||
const onSubmit = () => {
|
||||
syncDraft()
|
||||
submitPrompt(promptCopy(draft))
|
||||
submitPrompt(clonePrompt(draft))
|
||||
}
|
||||
|
||||
const submitText = (text: string) => {
|
||||
@@ -1233,6 +1296,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
refresh()
|
||||
scheduleRows()
|
||||
},
|
||||
replaceDraft,
|
||||
replacePrompt: restore,
|
||||
bind,
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
// Question UI body for the direct-mode footer.
|
||||
//
|
||||
// Renders inside the footer when the reducer pushes a FooterView of type
|
||||
// "question". Supports single-question and multi-question flows:
|
||||
//
|
||||
// Single question: options list with up/down selection, digit shortcuts,
|
||||
// and optional custom text input.
|
||||
//
|
||||
// Multi-question: tabbed interface where each question is a tab, plus a
|
||||
// final "Confirm" tab that shows all answers for review. Tab/shift-tab
|
||||
// or left/right to navigate between questions.
|
||||
//
|
||||
// All state logic lives in question.shared.ts as a pure state machine.
|
||||
// This component just renders it and dispatches keyboard events.
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import type { QuestionV2Request } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
createQuestionBodyState,
|
||||
questionConfirm,
|
||||
questionCustom,
|
||||
questionInfo,
|
||||
questionInput,
|
||||
questionMove,
|
||||
questionOther,
|
||||
questionPicked,
|
||||
questionReject,
|
||||
questionSave,
|
||||
questionSelect,
|
||||
questionSetEditing,
|
||||
questionSetSelected,
|
||||
questionSetSubmitting,
|
||||
questionSetTab,
|
||||
questionSingle,
|
||||
questionStoreCustom,
|
||||
questionSubmit,
|
||||
questionSync,
|
||||
questionTabs,
|
||||
questionTotal,
|
||||
} from "./question.shared"
|
||||
import { footerWidthPolicy } from "./footer.width"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { QuestionReject, QuestionReply } from "./types"
|
||||
|
||||
export function RunQuestionBody(props: {
|
||||
request: QuestionV2Request
|
||||
theme: RunFooterTheme
|
||||
onReply: (input: QuestionReply) => void | Promise<void>
|
||||
onReject: (input: QuestionReject) => void | Promise<void>
|
||||
}) {
|
||||
const dims = useTerminalDimensions()
|
||||
const [state, setState] = createSignal(createQuestionBodyState(props.request.id))
|
||||
const single = createMemo(() => questionSingle(props.request))
|
||||
const confirm = createMemo(() => questionConfirm(props.request, state()))
|
||||
const info = createMemo(() => questionInfo(props.request, state()))
|
||||
const input = createMemo(() => questionInput(state()))
|
||||
const other = createMemo(() => questionOther(props.request, state()))
|
||||
const picked = createMemo(() => questionPicked(state()))
|
||||
const disabled = createMemo(() => state().submitting)
|
||||
const narrow = createMemo(() => footerWidthPolicy(dims().width).dialog.narrow)
|
||||
const verb = createMemo(() => {
|
||||
if (confirm()) {
|
||||
return "submit"
|
||||
}
|
||||
|
||||
if (info()?.multiple) {
|
||||
return "toggle"
|
||||
}
|
||||
|
||||
if (single()) {
|
||||
return "submit"
|
||||
}
|
||||
|
||||
return "confirm"
|
||||
})
|
||||
let area: TextareaRenderable | undefined
|
||||
|
||||
createEffect(() => {
|
||||
setState((prev) => questionSync(prev, props.request.id))
|
||||
})
|
||||
|
||||
const setTab = (tab: number) => {
|
||||
setState((prev) => questionSetTab(prev, tab))
|
||||
}
|
||||
|
||||
const move = (dir: -1 | 1) => {
|
||||
setState((prev) => questionMove(prev, props.request, dir))
|
||||
}
|
||||
|
||||
const beginReply = async (input: QuestionReply) => {
|
||||
setState((prev) => questionSetSubmitting(prev, true))
|
||||
|
||||
try {
|
||||
await props.onReply(input)
|
||||
} catch {
|
||||
setState((prev) => questionSetSubmitting(prev, false))
|
||||
}
|
||||
}
|
||||
|
||||
const beginReject = async (input: QuestionReject) => {
|
||||
setState((prev) => questionSetSubmitting(prev, true))
|
||||
|
||||
try {
|
||||
await props.onReject(input)
|
||||
} catch {
|
||||
setState((prev) => questionSetSubmitting(prev, false))
|
||||
}
|
||||
}
|
||||
|
||||
const saveCustom = () => {
|
||||
const cur = state()
|
||||
const next = questionSave(cur, props.request)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const choose = (selected: number) => {
|
||||
const base = state()
|
||||
const cur = questionSetSelected(base, selected)
|
||||
const next = questionSelect(cur, props.request)
|
||||
if (next.state !== base) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const mark = (selected: number) => {
|
||||
setState((prev) => questionSetSelected(prev, selected))
|
||||
}
|
||||
|
||||
const select = () => {
|
||||
const cur = state()
|
||||
const next = questionSelect(cur, props.request)
|
||||
if (next.state !== cur) {
|
||||
setState(next.state)
|
||||
}
|
||||
|
||||
if (!next.reply) {
|
||||
return
|
||||
}
|
||||
|
||||
void beginReply(next.reply)
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
void beginReply(questionSubmit(props.request, state()))
|
||||
}
|
||||
|
||||
const reject = () => {
|
||||
void beginReject(questionReject(props.request))
|
||||
}
|
||||
|
||||
useKeyboard((event) => {
|
||||
const cur = state()
|
||||
if (cur.submitting) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (cur.editing) {
|
||||
if (event.name === "escape") {
|
||||
setState((prev) => questionSetEditing(prev, false))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && (event.name === "left" || event.name === "h")) {
|
||||
setTab((cur.tab - 1 + questionTabs(props.request)) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && (event.name === "right" || event.name === "l")) {
|
||||
setTab((cur.tab + 1) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (!single() && event.name === "tab") {
|
||||
const dir = event.shift ? -1 : 1
|
||||
setTab((cur.tab + dir + questionTabs(props.request)) % questionTabs(props.request))
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (questionConfirm(props.request, cur)) {
|
||||
if (event.name === "return") {
|
||||
submit()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
reject()
|
||||
event.preventDefault()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const total = questionTotal(props.request, cur)
|
||||
const max = Math.min(total, 9)
|
||||
const digit = Number(event.name)
|
||||
if (!Number.isNaN(digit) && digit >= 1 && digit <= max) {
|
||||
choose(digit - 1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "up" || event.name === "k") {
|
||||
move(-1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "down" || event.name === "j") {
|
||||
move(1)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "return") {
|
||||
select()
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.name === "escape") {
|
||||
reject()
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!state().editing || !area || area.isDestroyed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (area.plainText !== input()) {
|
||||
area.setText(input())
|
||||
area.cursorOffset = input().length
|
||||
}
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (!area || area.isDestroyed || !state().editing) {
|
||||
return
|
||||
}
|
||||
|
||||
area.focus()
|
||||
area.cursorOffset = area.plainText.length
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<box width="100%" height="100%" flexDirection="column">
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={3}
|
||||
paddingTop={1}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
backgroundColor={props.theme.surface}
|
||||
>
|
||||
<Show when={!single()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1} flexShrink={0}>
|
||||
<For each={props.request.questions}>
|
||||
{(item, index) => {
|
||||
const active = () => state().tab === index()
|
||||
const answered = () => (state().answers[index()]?.length ?? 0) > 0
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={active() ? props.theme.highlight : props.theme.surface}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) setTab(index())
|
||||
}}
|
||||
>
|
||||
<text fg={active() ? props.theme.surface : answered() ? props.theme.text : props.theme.muted}>
|
||||
{item.header}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={confirm() ? props.theme.highlight : props.theme.surface}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) setTab(props.request.questions.length)
|
||||
}}
|
||||
>
|
||||
<text fg={confirm() ? props.theme.surface : props.theme.muted}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!confirm()}
|
||||
fallback={
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column" gap={1}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={props.theme.text}>Review</text>
|
||||
</box>
|
||||
<For each={props.request.questions}>
|
||||
{(item, index) => {
|
||||
const value = () => state().answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text wrapMode="word">
|
||||
<span style={{ fg: props.theme.muted }}>{item.header}:</span>{" "}
|
||||
<span style={{ fg: answered() ? props.theme.text : props.theme.error }}>
|
||||
{answered() ? value() : "(not answered)"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
}
|
||||
>
|
||||
<box width="100%" flexGrow={1} flexShrink={1} paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={props.theme.text} wrapMode="word">
|
||||
{info()?.question}
|
||||
{info()?.multiple ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<box flexGrow={1} flexShrink={1}>
|
||||
<scrollbox
|
||||
width="100%"
|
||||
height="100%"
|
||||
verticalScrollbarOptions={{
|
||||
trackOptions: {
|
||||
backgroundColor: props.theme.surface,
|
||||
foregroundColor: props.theme.line,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<box width="100%" flexDirection="column">
|
||||
<For each={info()?.options ?? []}>
|
||||
{(item, index) => {
|
||||
const active = () => state().selected === index()
|
||||
const hit = () => state().answers[state().tab]?.includes(item.label) ?? false
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
onMouseOver={() => {
|
||||
if (!disabled()) {
|
||||
mark(index())
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (!disabled()) {
|
||||
mark(index())
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) {
|
||||
choose(index())
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? props.theme.line : undefined} paddingRight={1}>
|
||||
<text fg={active() ? props.theme.highlight : props.theme.muted}>{`${index() + 1}.`}</text>
|
||||
</box>
|
||||
<box backgroundColor={active() ? props.theme.line : undefined}>
|
||||
<text
|
||||
fg={active() ? props.theme.highlight : hit() ? props.theme.success : props.theme.text}
|
||||
>
|
||||
{info()?.multiple ? `[${hit() ? "✓" : " "}] ${item.label}` : item.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{hit() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
{item.description}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={questionCustom(props.request, state())}>
|
||||
<box
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
onMouseOver={() => {
|
||||
if (!disabled()) {
|
||||
mark(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
onMouseDown={() => {
|
||||
if (!disabled()) {
|
||||
mark(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!disabled()) {
|
||||
choose(info()?.options.length ?? 0)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={other() ? props.theme.line : undefined} paddingRight={1}>
|
||||
<text
|
||||
fg={other() ? props.theme.highlight : props.theme.muted}
|
||||
>{`${(info()?.options.length ?? 0) + 1}.`}</text>
|
||||
</box>
|
||||
<box backgroundColor={other() ? props.theme.line : undefined}>
|
||||
<text
|
||||
fg={other() ? props.theme.highlight : picked() ? props.theme.success : props.theme.text}
|
||||
>
|
||||
{info()?.multiple
|
||||
? `[${picked() ? "✓" : " "}] Type your own answer`
|
||||
: "Type your own answer"}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!info()?.multiple}>
|
||||
<text fg={props.theme.success}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show
|
||||
when={state().editing}
|
||||
fallback={
|
||||
<Show when={input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
{input()}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
width="100%"
|
||||
minHeight={1}
|
||||
maxHeight={4}
|
||||
wrapMode="word"
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={props.theme.muted}
|
||||
textColor={props.theme.text}
|
||||
focusedTextColor={props.theme.text}
|
||||
backgroundColor={props.theme.surface}
|
||||
focusedBackgroundColor={props.theme.surface}
|
||||
cursorColor={props.theme.text}
|
||||
focused={!disabled()}
|
||||
onSubmit={saveCustom}
|
||||
onContentChange={() => {
|
||||
if (!area || area.isDestroyed || disabled()) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = area.plainText
|
||||
setState((prev) => questionStoreCustom(prev, prev.tab, text))
|
||||
}}
|
||||
ref={(item) => {
|
||||
area = item
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
flexShrink={0}
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent={narrow() ? "flex-start" : "space-between"}
|
||||
alignItems={narrow() ? "flex-start" : "center"}
|
||||
>
|
||||
<Show
|
||||
when={!disabled()}
|
||||
fallback={
|
||||
<text fg={props.theme.muted} wrapMode="word">
|
||||
Waiting for question event...
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box
|
||||
flexDirection={narrow() ? "column" : "row"}
|
||||
gap={narrow() ? 1 : 2}
|
||||
flexShrink={0}
|
||||
width={narrow() ? "100%" : undefined}
|
||||
>
|
||||
<Show
|
||||
when={!state().editing}
|
||||
fallback={
|
||||
<>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>save</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>cancel</span>
|
||||
</text>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Show when={!single()}>
|
||||
<text fg={props.theme.text}>
|
||||
{"⇆"} <span style={{ fg: props.theme.muted }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm()}>
|
||||
<text fg={props.theme.text}>
|
||||
{"↑↓"} <span style={{ fg: props.theme.muted }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={props.theme.text}>
|
||||
enter <span style={{ fg: props.theme.muted }}>{verb()}</span>
|
||||
</text>
|
||||
<text fg={props.theme.text}>
|
||||
esc <span style={{ fg: props.theme.muted }}>dismiss</span>
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
+7
-4
@@ -1,11 +1,11 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import type { ScrollBoxRenderable } from "@opentui/core"
|
||||
import { useKeyboard } from "@opentui/solid"
|
||||
import { registerOpencodeSpinner } from "../component/register-spinner"
|
||||
import { registerOpencodeSpinner } from "@opencode-ai/tui/component/register-spinner"
|
||||
import { Show, createMemo, indexArray } from "solid-js"
|
||||
import { SPINNER_FRAMES } from "../component/spinner-frames"
|
||||
import { SPINNER_FRAMES } from "@opencode-ai/tui/component/spinner"
|
||||
import { RunEntryContent, separatorRows } from "./scrollback.writer"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab } from "./types"
|
||||
import type { FooterSubagentDetail, FooterSubagentTab, RunDiffStyle } from "./types"
|
||||
import type { RunFooterTheme, RunTheme } from "./theme"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
@@ -51,6 +51,8 @@ export function RunFooterSubagentBody(props: {
|
||||
index: () => number
|
||||
total: () => number
|
||||
detail: () => FooterSubagentDetail | undefined
|
||||
width: () => number
|
||||
diffStyle?: RunDiffStyle
|
||||
onCycle: (dir: -1 | 1) => void
|
||||
onClose: () => void
|
||||
// Formatted interrupt shortcut from the registered keymap binding; the
|
||||
@@ -61,6 +63,7 @@ export function RunFooterSubagentBody(props: {
|
||||
const footer = createMemo(() => theme().footer)
|
||||
const tab = createMemo(() => props.tab())
|
||||
const commits = createMemo(() => props.detail()?.commits ?? [])
|
||||
const opts = createMemo(() => ({ diffStyle: props.diffStyle }))
|
||||
const scrollbar = createMemo(() => ({
|
||||
trackOptions: {
|
||||
backgroundColor: footer().surface,
|
||||
@@ -86,7 +89,7 @@ export function RunFooterSubagentBody(props: {
|
||||
const rows = indexArray(commits, (commit, index) => (
|
||||
<box flexDirection="column" gap={0} flexShrink={0}>
|
||||
{index > 0 && separatorRows(commits()[index - 1], commit()) > 0 ? <box height={1} flexShrink={0} /> : null}
|
||||
<RunEntryContent commit={commit()} theme={theme()} />
|
||||
<RunEntryContent commit={commit()} theme={theme()} opts={opts()} width={props.width()} />
|
||||
</box>
|
||||
))
|
||||
let scroll: ScrollBoxRenderable | undefined
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user