mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 01:46:23 +00:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b461266604 | ||
|
|
c7368808af | ||
|
|
6ce51f3714 | ||
|
|
fa7209e169 | ||
|
|
b32d8c3e58 | ||
|
|
6af8515f69 | ||
|
|
5c50edb9bb | ||
|
|
fcddc84225 |
@@ -29,6 +29,46 @@ await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
|
||||
|
||||
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.
|
||||
|
||||
## MiniMax
|
||||
|
||||
MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
|
||||
|
||||
```ts
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode-ai/ai"
|
||||
import { MiniMax } from "@opencode-ai/ai/providers"
|
||||
import { RequestExecutor } from "@opencode-ai/ai/route"
|
||||
|
||||
const minimax = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY })
|
||||
const request = LLM.request({
|
||||
model: minimax.model("MiniMax-M3"), // also minimax.messages("MiniMax-M3")
|
||||
prompt: "What is 173 multiplied by 219?",
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
generation: { maxTokens: 1536 },
|
||||
})
|
||||
|
||||
const layer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
const response = await Effect.runPromise(LLMClient.generate(request).pipe(Effect.provide(layer)))
|
||||
console.log(response.text)
|
||||
```
|
||||
|
||||
Select `minimax.chat("MiniMax-M3")` or `minimax.responses("MiniMax-M3")` for MiniMax's native Chat Completions
|
||||
and Responses APIs. The matching package entrypoints are `@opencode-ai/ai/providers/minimax/messages`,
|
||||
`@opencode-ai/ai/providers/minimax/chat`, and `@opencode-ai/ai/providers/minimax/responses`.
|
||||
|
||||
- **Messages:** M3 thinking defaults off. Set `thinking: { type: "adaptive" }` to enable it or
|
||||
`thinking: { type: "disabled" }` to disable it.
|
||||
- **Chat:** M3 thinking defaults on and uses the same `thinking` control. The provider enables `reasoning_split`
|
||||
by default so reasoning is separate from answer text; `reasoningSplit: false` selects native `<think>`-tagged text.
|
||||
- **Responses:** M3 reasoning defaults off. `reasoningEffort: "none"` disables it; `"minimal"`, `"low"`,
|
||||
`"medium"`, and `"high"` enable reasoning without changing its depth.
|
||||
|
||||
M2.x models always think, even when a disabling option is supplied. For tool continuations, retain the complete
|
||||
`response.message` in history before adding `Message.tool(...)` results; this preserves reasoning and any signatures.
|
||||
|
||||
The default API bases are `https://api.minimax.io/anthropic/v1` for Messages and `https://api.minimax.io/v1` for
|
||||
Chat and Responses. `configure({ baseURL })` replaces the selected API's base, including its version prefix.
|
||||
|
||||
## Image generation
|
||||
|
||||
Use `Image.generate` with an image model for direct asset generation:
|
||||
|
||||
@@ -16,6 +16,7 @@ export * as GoogleVertexChat from "./google-vertex-chat.js"
|
||||
export * as GoogleVertexMessages from "./google-vertex-messages.js"
|
||||
export * as GoogleVertexResponses from "./google-vertex-responses.js"
|
||||
export * as Groq from "./groq.js"
|
||||
export * as MiniMax from "./minimax.js"
|
||||
export * as Mistral from "./mistral.js"
|
||||
export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("minimax")
|
||||
|
||||
export type MessagesOptionsInput = {
|
||||
/** M3 defaults to disabled; M2.x always thinks. */
|
||||
readonly thinking?: { readonly type: "adaptive" | "disabled" }
|
||||
readonly metadata?: AnthropicMessages.OptionsInput["metadata"]
|
||||
}
|
||||
|
||||
export type ChatOptionsInput = {
|
||||
/** M3 defaults to adaptive; M2.x always thinks. */
|
||||
readonly thinking?: { readonly type: "adaptive" | "disabled" | (string & {}) }
|
||||
/** Separates reasoning from text. Defaults to true. */
|
||||
readonly reasoningSplit?: boolean
|
||||
}
|
||||
|
||||
export type ResponsesOptionsInput = {
|
||||
/** M3 defaults to none. Other supported values enable thinking without changing its depth. */
|
||||
readonly reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | (string & {})
|
||||
}
|
||||
|
||||
export type ProviderOptionsInput = MessagesOptionsInput | ChatOptionsInput | ResponsesOptionsInput
|
||||
|
||||
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
/** Overrides the selected API's base URL, including its version prefix. */
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings<Options = MessagesOptionsInput> extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
|
||||
const ChatOptions = Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
|
||||
reasoningSplit: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const chatProtocol = Protocol.make({
|
||||
id: "minimax-chat",
|
||||
body: {
|
||||
schema: Schema.Struct({
|
||||
...OpenAIChat.bodyFields,
|
||||
thinking: ChatOptions.fields.thinking,
|
||||
reasoning_split: Schema.Boolean,
|
||||
}),
|
||||
from: Effect.fn("MiniMax.chatFromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(ChatOptions))(
|
||||
request.providerOptions ?? {},
|
||||
)
|
||||
return {
|
||||
...(yield* OpenAIChat.protocol.body.from(request)),
|
||||
thinking: options.thinking,
|
||||
// MiniMax otherwise embeds <think> tags in ordinary assistant text.
|
||||
reasoning_split: options.reasoningSplit ?? true,
|
||||
}
|
||||
}),
|
||||
},
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
const messagesRoute = Route.make({
|
||||
id: "minimax-messages",
|
||||
provider: id,
|
||||
providerMetadataKey: "minimax",
|
||||
protocol: AnthropicMessages.protocol,
|
||||
endpoint: Endpoint.path("/messages", { baseURL: "https://api.minimax.io/anthropic/v1" }),
|
||||
framing: AnthropicMessages.framing,
|
||||
headers: () => ({ "anthropic-version": "2023-06-01" }),
|
||||
})
|
||||
|
||||
const chatRoute = Route.make({
|
||||
id: "minimax-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "minimax",
|
||||
protocol: chatProtocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.minimax.io/v1" }),
|
||||
framing: OpenAIChat.framing,
|
||||
})
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "minimax-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "minimax",
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path("/responses", { baseURL: "https://api.minimax.io/v1" }),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export const routes = [messagesRoute, chatRoute, responsesRoute]
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...rest } = input
|
||||
const defaults = {
|
||||
...rest,
|
||||
endpoint: baseURL === undefined ? undefined : { baseURL },
|
||||
auth: AuthOptions.bearer(input, "MINIMAX_API_KEY"),
|
||||
}
|
||||
const messages = (modelID: string | ModelID) =>
|
||||
messagesRoute.with(defaults).model<MessagesOptionsInput>({ id: modelID })
|
||||
const chat = (modelID: string | ModelID) =>
|
||||
chatRoute.with(defaults).model<ChatOptionsInput>({
|
||||
id: modelID,
|
||||
compatibility: { supportsStore: false, supportsStrictMode: false },
|
||||
})
|
||||
const responses = (modelID: string | ModelID) =>
|
||||
responsesRoute.with(defaults).model<ResponsesOptionsInput>({ id: modelID })
|
||||
return { id, model: messages, messages, chat, responses, configure }
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings<MessagesOptionsInput>, MessagesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export const messages = provider.messages
|
||||
export const chat = provider.chat
|
||||
export const responses = provider.responses
|
||||
|
||||
export * as MiniMax from "./minimax.js"
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ProviderPackage } from "../../provider-package.js"
|
||||
import { MiniMax } from "../minimax.js"
|
||||
|
||||
export type Settings = MiniMax.Settings<MiniMax.ChatOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (modelID, settings) =>
|
||||
MiniMax.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).chat(modelID)
|
||||
@@ -0,0 +1 @@
|
||||
export { model, type Settings, type MessagesOptionsInput } from "../minimax.js"
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ProviderPackage } from "../../provider-package.js"
|
||||
import { MiniMax } from "../minimax.js"
|
||||
|
||||
export type Settings = MiniMax.Settings<MiniMax.ResponsesOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, MiniMax.ResponsesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) =>
|
||||
MiniMax.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).responses(modelID)
|
||||
+56
File diff suppressed because one or more lines are too long
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:minimax-chat",
|
||||
"provider:minimax",
|
||||
"protocol:minimax-chat",
|
||||
"text",
|
||||
"usage",
|
||||
"thinking-off"
|
||||
],
|
||||
"name": "minimax-chat/m3-streams-text-with-thinking-disabled",
|
||||
"recordedAt": "2026-09-07T16:53:20.976Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_completion_tokens\":1536,\"thinking\":{\"type\":\"disabled\"},\"reasoning_split\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "data: {\"id\":\"d8d409a572a67df400527c6592b480df\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\"}}],\"created\":1788800000,\"model\":\"MiniMax-M3\",\"object\":\"chat.completion.chunk\",\"usage\":null,\"service_tier\":\"standard\"}\n\ndata: {\"id\":\"d8d409a572a67df400527c6592b480df\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"378\",\"role\":\"assistant\"}}],\"created\":1788800000,\"model\":\"MiniMax-M3\",\"object\":\"chat.completion.chunk\",\"usage\":null,\"service_tier\":\"standard\"}\n\ndata: {\"id\":\"d8d409a572a67df400527c6592b480df\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"87\",\"role\":\"assistant\"}}],\"created\":1788800000,\"model\":\"MiniMax-M3\",\"object\":\"chat.completion.chunk\",\"usage\":null,\"service_tier\":\"standard\"}\n\ndata: {\"id\":\"d8d409a572a67df400527c6592b480df\",\"choices\":[{\"finish_reason\":\"stop\",\"index\":0,\"delta\":{\"role\":\"assistant\"}}],\"created\":1788800000,\"model\":\"MiniMax-M3\",\"object\":\"chat.completion.chunk\",\"usage\":null,\"service_tier\":\"standard\"}\n\ndata: {\"id\":\"d8d409a572a67df400527c6592b480df\",\"choices\":[],\"created\":1788800000,\"model\":\"MiniMax-M3\",\"object\":\"chat.completion.chunk\",\"usage\":{\"total_tokens\":182,\"total_characters\":0,\"prompt_tokens\":179,\"completion_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":128}},\"service_tier\":\"standard\",\"base_resp\":{\"status_code\":0,\"status_msg\":\"\"}}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "MiniMax-M2.7",
|
||||
"tags": [
|
||||
"prefix:minimax-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"text",
|
||||
"usage",
|
||||
"reasoning"
|
||||
],
|
||||
"name": "minimax-messages/m2-7-streams-default-thinking",
|
||||
"recordedAt": "2026-09-07T16:53:20.721Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M2.7\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":1536}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"bd0c49bb4f5e6f26b77bab9b5a917484\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M2.7\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":57,\"output_tokens\":0}}}\n\nevent: ping\ndata: {\"type\":\"ping\"}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" asks: \\\"What is 173 multiplied by 219? Reply with only the final integer.\\\" So we compute 173 * 219. Compute:\\n\\n173 * 219 = \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"173 * (200 + 19) = 173*200 + 173*19 = 34600 + (173*19). 173*19 = 173*20\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" - 173 = 3460 - 173 = 3287. So total = 34600 + 3287 = 37887.\\n\\nAlternatively compute directly: 219 * \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"173 = 219 * 173 = (219 * 100) + (219 * 70) + (219 * 3) = 21900 + 15330 + 657\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" = 37887.\\n\\nThus answer is 37887. We must reply with only the final integer: \\\"37887\\\". No extra text.\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"1625da2f905565076ee90b9c2919db39b1719731c0820eca2f2a1c9c28324e87\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"37887\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":57,\"output_tokens\":187,\"output_tokens_details\":{\"thinking_tokens\":184}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:minimax-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"tool-loop",
|
||||
"reasoning",
|
||||
"continuation",
|
||||
"usage"
|
||||
],
|
||||
"name": "minimax-messages/m3-continues-a-tool-loop-with-adaptive-thinking",
|
||||
"recordedAt": "2026-09-07T16:53:25.185Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"auto\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"6a532037868639a6189e7c0b9e1c9aa2\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"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\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"The user wants me\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" to look up the\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" current weather in Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" using the get_\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"weather tool, then\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" report it\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" in one short sentence\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\". Let\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" me call the tool\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"f05cb5f4873950f23d194289c41d79b96ebd37c9411a45b306ed44f135a910d4\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_01a07cc9f2d47d83a6424ff3\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":239,\"output_tokens\":63,\"cache_read_input_tokens\":203,\"service_tier\":\"standard\",\"output_tokens_details\":{\"thinking_tokens\":33}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
},
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"The user wants me to look up the current weather in Paris using the get_weather tool, then report it in one short sentence. Let me call the tool.\",\"signature\":\"f05cb5f4873950f23d194289c41d79b96ebd37c9411a45b306ed44f135a910d4\"},{\"type\":\"tool_use\",\"id\":\"call_01a07cc9f2d47d83a6424ff3\",\"name\":\"get_weather\",\"input\":{\"city\":\"Paris\"}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_01a07cc9f2d47d83a6424ff3\",\"content\":\"{\\\"condition\\\":\\\"sunny\\\",\\\"temperature\\\":\\\"18C\\\"}\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"none\"},\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"e9e7ea89f63eca1cd17a037aef28eed1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"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\":\"The weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in Paris is sunny\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" with\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" a\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" temperature of 18\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"°C.\"}}\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\":164,\"output_tokens\":16,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:minimax-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"tool",
|
||||
"thinking-off",
|
||||
"usage"
|
||||
],
|
||||
"name": "minimax-messages/m3-generates-a-named-tool-call-with-default-thinking-off",
|
||||
"recordedAt": "2026-09-07T16:53:24.366Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"tool_choice\":{\"type\":\"tool\",\"name\":\"get_weather\"},\"stream\":true,\"max_tokens\":512}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"a60a8adb2eff7545f809b9df2689f1b8\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"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\":\"I'll\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" look\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" up the current weather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" in Paris for you\"}}\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: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"call_b0246853f3c4432ea455cc99\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\"},\"usage\":{\"input_tokens\":401,\"output_tokens\":39,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:minimax-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"text",
|
||||
"usage",
|
||||
"reasoning"
|
||||
],
|
||||
"name": "minimax-messages/m3-streams-adaptive-thinking",
|
||||
"recordedAt": "2026-09-07T16:53:16.470Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"adaptive\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"ece24e3da050a8b1d7e8b1ad924a9e17\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"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\":\"thinking\",\"thinking\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"173\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" × 219\\n\\n\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"173 × 200\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" = 346\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"00\\n173 ×\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 19 = \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"173 × 20\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" - 173 =\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 3460\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" - 173 =\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" 3287\\n\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"34600 + \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"3287 = \"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"37887\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"signature_delta\",\"signature\":\"af0089d30b3ee7aff92a96e68064f4ed9346ca97de4f579555d2b3e1e61b53ea\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"text_delta\",\"text\":\"37887\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"input_tokens\":64,\"output_tokens\":54,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\",\"output_tokens_details\":{\"thinking_tokens\":49}}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:minimax-messages",
|
||||
"provider:minimax",
|
||||
"protocol:anthropic-messages",
|
||||
"text",
|
||||
"usage",
|
||||
"thinking-off"
|
||||
],
|
||||
"name": "minimax-messages/m3-streams-text-with-thinking-disabled",
|
||||
"recordedAt": "2026-09-07T16:53:16.028Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/anthropic/v1/messages",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"stream\":true,\"max_tokens\":1536,\"thinking\":{\"type\":\"disabled\"}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"01664c047875a0f573c966467c8cccc1\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"MiniMax-M3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"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\":\"378\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"87\"}}\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\":51,\"output_tokens\":3,\"cache_read_input_tokens\":128,\"service_tier\":\"standard\"}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+56
File diff suppressed because one or more lines are too long
Vendored
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "MiniMax-M3",
|
||||
"tags": [
|
||||
"prefix:minimax-responses",
|
||||
"provider:minimax",
|
||||
"protocol:open-responses",
|
||||
"text",
|
||||
"usage",
|
||||
"thinking-off"
|
||||
],
|
||||
"name": "minimax-responses/m3-streams-text-with-effort-none",
|
||||
"recordedAt": "2026-09-07T16:53:21.223Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.minimax.io/v1/responses",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"MiniMax-M3\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}]}],\"reasoning\":{\"effort\":\"none\"},\"max_output_tokens\":1536,\"stream\":true}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream; charset=utf-8"
|
||||
},
|
||||
"body": "event: response.created\ndata: {\"type\":\"response.created\",\"sequence_number\":0,\"response\":{\"id\":\"40d878e9c7ecc67905d069cdf9bec7c0\",\"object\":\"response\",\"created_at\":1788800001,\"model\":\"MiniMax-M3\",\"status\":\"in_progress\",\"output\":[],\"output_text\":null,\"usage\":null,\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"metadata\":{},\"tools\":null,\"tool_choice\":\"auto\",\"temperature\":1,\"top_p\":0.95,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":{\"effort\":\"none\",\"summary\":null},\"max_output_tokens\":1536,\"parallel_tool_calls\":true,\"previous_response_id\":null,\"conversation\":null,\"store\":false,\"service_tier\":\"standard\",\"safety_identifier\":null,\"truncation\":\"disabled\"}}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"sequence_number\":1,\"response\":{\"id\":\"40d878e9c7ecc67905d069cdf9bec7c0\",\"object\":\"response\",\"created_at\":1788800001,\"model\":\"MiniMax-M3\",\"status\":\"in_progress\",\"output\":[],\"output_text\":null,\"usage\":null,\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"metadata\":{},\"tools\":null,\"tool_choice\":\"auto\",\"temperature\":1,\"top_p\":0.95,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":{\"effort\":\"none\",\"summary\":null},\"max_output_tokens\":1536,\"parallel_tool_calls\":true,\"previous_response_id\":null,\"conversation\":null,\"store\":false,\"service_tier\":\"standard\",\"safety_identifier\":null,\"truncation\":\"disabled\"}}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"sequence_number\":2,\"item\":{\"id\":\"40d878e9c7ecc67905d069cdf9bec7c0_msg\",\"type\":\"message\",\"status\":\"in_progress\",\"role\":\"assistant\",\"content\":[]},\"output_index\":0}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"sequence_number\":3,\"part\":{\"type\":\"output_text\",\"text\":\"\",\"annotations\":[]},\"output_index\":0,\"content_index\":0,\"item_id\":\"40d878e9c7ecc67905d069cdf9bec7c0_msg\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":4,\"output_index\":0,\"content_index\":0,\"item_id\":\"40d878e9c7ecc67905d069cdf9bec7c0_msg\",\"delta\":\"378\"}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"sequence_number\":5,\"output_index\":0,\"content_index\":0,\"item_id\":\"40d878e9c7ecc67905d069cdf9bec7c0_msg\",\"delta\":\"87\"}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"sequence_number\":6,\"output_index\":0,\"content_index\":0,\"item_id\":\"40d878e9c7ecc67905d069cdf9bec7c0_msg\",\"text\":\"37887\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"sequence_number\":7,\"part\":{\"type\":\"output_text\",\"text\":\"37887\",\"annotations\":[]},\"output_index\":0,\"content_index\":0,\"item_id\":\"40d878e9c7ecc67905d069cdf9bec7c0_msg\"}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"sequence_number\":8,\"item\":{\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"37887\",\"annotations\":[]}],\"id\":\"40d878e9c7ecc67905d069cdf9bec7c0_msg\",\"type\":\"message\"},\"output_index\":0}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"sequence_number\":9,\"response\":{\"id\":\"40d878e9c7ecc67905d069cdf9bec7c0\",\"object\":\"response\",\"created_at\":1788800001,\"model\":\"MiniMax-M3\",\"status\":\"completed\",\"output\":[{\"id\":\"40d878e9c7ecc67905d069cdf9bec7c0_msg\",\"type\":\"message\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"37887\",\"annotations\":null}]}],\"output_text\":\"37887\",\"usage\":{\"input_tokens\":179,\"output_tokens\":3,\"total_tokens\":182,\"input_tokens_details\":{\"cached_tokens\":128}},\"error\":null,\"incomplete_details\":null,\"instructions\":null,\"metadata\":{},\"tools\":null,\"tool_choice\":\"auto\",\"temperature\":1,\"top_p\":0.95,\"text\":{\"format\":{\"type\":\"text\"}},\"reasoning\":{\"effort\":\"none\",\"summary\":null},\"max_output_tokens\":1536,\"parallel_tool_calls\":true,\"previous_response_id\":null,\"conversation\":null,\"store\":false,\"service_tier\":\"standard\",\"safety_identifier\":null,\"truncation\":\"disabled\"}}\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { MiniMax } from "../../src/providers.js"
|
||||
|
||||
const minimax = MiniMax.configure()
|
||||
|
||||
LLM.request({ model: minimax.model("MiniMax-M3"), providerOptions: { thinking: { type: "adaptive" } } })
|
||||
LLM.request({ model: minimax.chat("MiniMax-M3"), providerOptions: { thinking: { type: "disabled" } } })
|
||||
LLM.request({ model: minimax.chat("MiniMax-M3"), providerOptions: { reasoningSplit: false } })
|
||||
LLM.request({ model: minimax.responses("MiniMax-M3"), providerOptions: { reasoningEffort: "minimal" } })
|
||||
LLM.request({ model: minimax.responses("MiniMax-M3"), providerOptions: { reasoningEffort: "future-effort" } })
|
||||
|
||||
LLM.request({
|
||||
model: minimax.model("MiniMax-M3"),
|
||||
// @ts-expect-error MiniMax Messages has no documented effort setting.
|
||||
providerOptions: { effort: "high" },
|
||||
})
|
||||
LLM.request({
|
||||
model: minimax.chat("MiniMax-M3"),
|
||||
// @ts-expect-error Chat reasoning_split is a boolean.
|
||||
providerOptions: { reasoningSplit: "true" },
|
||||
})
|
||||
LLM.request({
|
||||
model: minimax.responses("MiniMax-M3"),
|
||||
// @ts-expect-error MiniMax Responses uses reasoning effort rather than Messages thinking.
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
})
|
||||
@@ -37,6 +37,10 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/fireworks"),
|
||||
import("@opencode-ai/ai/providers/cloudflare-ai-gateway"),
|
||||
import("@opencode-ai/ai/providers/cloudflare-workers-ai"),
|
||||
import("@opencode-ai/ai/providers/minimax"),
|
||||
import("@opencode-ai/ai/providers/minimax/messages"),
|
||||
import("@opencode-ai/ai/providers/minimax/chat"),
|
||||
import("@opencode-ai/ai/providers/minimax/responses"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
@@ -47,6 +51,31 @@ describe("provider package entrypoints", () => {
|
||||
expect(modules[19].model).not.toBe(modules[20].model)
|
||||
})
|
||||
|
||||
test("maps MiniMax API entrypoints onto provider-owned routes", async () => {
|
||||
const modules = await Promise.all([
|
||||
import("@opencode-ai/ai/providers/minimax"),
|
||||
import("@opencode-ai/ai/providers/minimax/messages"),
|
||||
import("@opencode-ai/ai/providers/minimax/chat"),
|
||||
import("@opencode-ai/ai/providers/minimax/responses"),
|
||||
])
|
||||
expect(modules[0].model).toBe(modules[1].model)
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
}
|
||||
const routes = ["minimax-messages", "minimax-messages", "minimax-chat", "minimax-responses"]
|
||||
modules.forEach((module, index) => {
|
||||
const selected = module.model("MiniMax-M3", settings)
|
||||
expect(selected.provider).toBe("minimax")
|
||||
expect(selected.route.id).toBe(routes[index])
|
||||
expect(selected.route.endpoint.baseURL).toBe(settings.baseURL)
|
||||
expect(selected.route.defaults.headers).toEqual(settings.headers)
|
||||
expect(selected.route.defaults.http?.body).toEqual(settings.body)
|
||||
})
|
||||
})
|
||||
|
||||
test("maps DeepInfra package settings onto its native executable model", async () => {
|
||||
const DeepInfra = await import("@opencode-ai/ai/providers/deepinfra")
|
||||
const settings = {
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import {
|
||||
LLM,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
LLMResponse,
|
||||
Message,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
type LanguageModel,
|
||||
} from "../../src/index.js"
|
||||
import { MiniMax } from "../../src/providers.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const apiKey = process.env.MINIMAX_API_KEY ?? "fixture"
|
||||
const minimax = MiniMax.configure({ apiKey })
|
||||
const weather = ToolDefinition.make({
|
||||
name: "get_weather",
|
||||
description: "Get the current weather in a city",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string", enum: ["Paris"] } },
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
|
||||
const textCases: ReadonlyArray<{
|
||||
name: string
|
||||
api: string
|
||||
protocol: string
|
||||
model: LanguageModel
|
||||
reasoning: boolean
|
||||
body: Record<string, unknown>
|
||||
}> = [
|
||||
{
|
||||
name: "M3 streams text with thinking disabled",
|
||||
api: "messages",
|
||||
protocol: "anthropic-messages",
|
||||
model: MiniMax.configure({ apiKey, providerOptions: { thinking: { type: "disabled" } } }).model("MiniMax-M3"),
|
||||
reasoning: false,
|
||||
body: { thinking: { type: "disabled" } },
|
||||
},
|
||||
{
|
||||
name: "M3 streams adaptive thinking",
|
||||
api: "messages",
|
||||
protocol: "anthropic-messages",
|
||||
model: MiniMax.configure({ apiKey, providerOptions: { thinking: { type: "adaptive" } } }).messages("MiniMax-M3"),
|
||||
reasoning: true,
|
||||
body: { thinking: { type: "adaptive" } },
|
||||
},
|
||||
{
|
||||
name: "M2.7 streams default thinking",
|
||||
api: "messages",
|
||||
protocol: "anthropic-messages",
|
||||
model: minimax.model("MiniMax-M2.7"),
|
||||
reasoning: true,
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
name: "M3 streams text with thinking disabled",
|
||||
api: "chat",
|
||||
protocol: "minimax-chat",
|
||||
model: MiniMax.configure({ apiKey, providerOptions: { thinking: { type: "disabled" } } }).chat("MiniMax-M3"),
|
||||
reasoning: false,
|
||||
body: { thinking: { type: "disabled" }, reasoning_split: true },
|
||||
},
|
||||
{
|
||||
name: "M3 streams text with effort none",
|
||||
api: "responses",
|
||||
protocol: "open-responses",
|
||||
model: MiniMax.configure({ apiKey, providerOptions: { reasoningEffort: "none" } }).responses("MiniMax-M3"),
|
||||
reasoning: false,
|
||||
body: { reasoning: { effort: "none" } },
|
||||
},
|
||||
]
|
||||
|
||||
describe("MiniMax recorded", () => {
|
||||
for (const item of textCases) {
|
||||
const recorded = recordedTests({
|
||||
prefix: `minimax-${item.api}`,
|
||||
provider: "minimax",
|
||||
protocol: item.protocol,
|
||||
requires: ["MINIMAX_API_KEY"],
|
||||
metadata: { model: item.model.id },
|
||||
})
|
||||
recorded.effect.with(
|
||||
item.name,
|
||||
{ tags: ["text", "usage", item.reasoning ? "reasoning" : "thinking-off"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: item.model,
|
||||
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
|
||||
generation: { maxTokens: 1536 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body).toMatchObject(item.body)
|
||||
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.text).not.toContain("<think>")
|
||||
expect(response.reasoning.length > 0).toBe(item.reasoning)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(item.reasoning)
|
||||
expect(response.events.some(LLMEvent.is.textDelta)).toBe(true)
|
||||
expectUsage(response)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
}
|
||||
|
||||
const messages = recordedTests({
|
||||
prefix: "minimax-messages",
|
||||
provider: "minimax",
|
||||
protocol: "anthropic-messages",
|
||||
requires: ["MINIMAX_API_KEY"],
|
||||
metadata: { model: "MiniMax-M3" },
|
||||
})
|
||||
|
||||
messages.effect.with(
|
||||
"M3 generates a named tool call with default thinking off",
|
||||
{ tags: ["tool", "thinking-off", "usage"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: minimax.model("MiniMax-M3"),
|
||||
prompt: "Use get_weather to look up the current weather in Paris.",
|
||||
tools: [weather],
|
||||
toolChoice: ToolChoice.named("get_weather"),
|
||||
generation: { maxTokens: 512 },
|
||||
}),
|
||||
)
|
||||
expect(response.finishReason.normalized).toBe("tool-calls")
|
||||
expect(response.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
|
||||
expect(response.reasoning).toBe("")
|
||||
expect(response.events.some(LLMEvent.is.toolInputDelta)).toBe(true)
|
||||
expectUsage(response)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
const loops: ReadonlyArray<{ api: string; protocol: string; mode: string; model: LanguageModel }> = [
|
||||
{
|
||||
api: "messages",
|
||||
protocol: "anthropic-messages",
|
||||
mode: "adaptive thinking",
|
||||
model: MiniMax.configure({ apiKey, providerOptions: { thinking: { type: "adaptive" } } }).model("MiniMax-M3"),
|
||||
},
|
||||
{
|
||||
api: "chat",
|
||||
protocol: "minimax-chat",
|
||||
mode: "default thinking",
|
||||
model: minimax.chat("MiniMax-M3"),
|
||||
},
|
||||
{
|
||||
api: "responses",
|
||||
protocol: "open-responses",
|
||||
mode: "effort minimal",
|
||||
model: MiniMax.configure({ apiKey, providerOptions: { reasoningEffort: "minimal" } }).responses("MiniMax-M3"),
|
||||
},
|
||||
]
|
||||
|
||||
for (const item of loops) {
|
||||
const recorded = recordedTests({
|
||||
prefix: `minimax-${item.api}`,
|
||||
provider: "minimax",
|
||||
protocol: item.protocol,
|
||||
requires: ["MINIMAX_API_KEY"],
|
||||
metadata: { model: item.model.id },
|
||||
})
|
||||
recorded.effect.with(
|
||||
`M3 continues a tool loop with ${item.mode}`,
|
||||
{ tags: ["tool", "tool-loop", "reasoning", "continuation", "usage"] },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: item.model,
|
||||
prompt:
|
||||
"Look up the current weather in Paris using get_weather before answering. After receiving the result, report the weather in one short sentence.",
|
||||
tools: [weather],
|
||||
toolChoice: "auto",
|
||||
generation: { maxTokens: 1536 },
|
||||
})
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.normalized).toBe("tool-calls")
|
||||
expect(first.toolCalls).toHaveLength(1)
|
||||
expect(first.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
|
||||
expect(first.reasoning.length).toBeGreaterThan(0)
|
||||
expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
expectUsage(first)
|
||||
|
||||
const followUp = LLMRequest.update(request, {
|
||||
toolChoice: ToolChoice.make("none"),
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
...first.toolCalls.map((call) =>
|
||||
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
|
||||
),
|
||||
],
|
||||
})
|
||||
const replay = yield* compileRequest(followUp)
|
||||
const reasoning = first.message.content.filter((part) => part.type === "reasoning")
|
||||
if (item.api === "messages") {
|
||||
reasoning.forEach((part) => expect(part.providerMetadata?.minimax?.signature).toEqual(expect.any(String)))
|
||||
expect(replay.body.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: expect.arrayContaining(
|
||||
reasoning.map((part) => ({
|
||||
type: "thinking",
|
||||
thinking: part.text,
|
||||
signature: part.providerMetadata?.minimax?.signature,
|
||||
})),
|
||||
),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (item.api === "chat") {
|
||||
expect(replay.body.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
reasoning_content: first.reasoning,
|
||||
reasoning_details: reasoning.flatMap(
|
||||
(part) => part.providerMetadata?.minimax?.reasoningDetails ?? [],
|
||||
),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}
|
||||
if (item.api === "responses") {
|
||||
expect(replay.body.input).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "reasoning",
|
||||
summary: expect.arrayContaining([{ type: "summary_text", text: first.reasoning }]),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
const second = yield* LLMClient.generate(followUp)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
expect(second.toolCalls).toHaveLength(0)
|
||||
expect(second.text).toContain("Paris")
|
||||
expect(second.text.toLowerCase()).toContain("sunny")
|
||||
expectUsage(second)
|
||||
}),
|
||||
120_000,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
function expectUsage(response: LLMResponse) {
|
||||
expect(response.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.outputTokens).toBeGreaterThan(0)
|
||||
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { LLM } from "../../src/index.js"
|
||||
import { MiniMax } from "../../src/providers.js"
|
||||
import { AnthropicMessages } from "../../src/protocols/anthropic-messages.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Auth } from "../../src/route/auth.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { Endpoint } from "../../src/route/endpoint.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
describe("MiniMax provider", () => {
|
||||
test("composes the baseline Messages and Responses protocols", () => {
|
||||
const minimax = MiniMax.configure()
|
||||
expect(minimax.model).toBe(minimax.messages)
|
||||
expect(minimax.model("MiniMax-M3").route.body).toBe(AnthropicMessages.protocol.body)
|
||||
expect(minimax.responses("MiniMax-M3").route.body).toBe(OpenResponses.protocol.body)
|
||||
})
|
||||
|
||||
it.effect("owns API endpoints, provider identity and environment bearer authentication", () =>
|
||||
Effect.gen(function* () {
|
||||
const minimax = MiniMax.configure()
|
||||
for (const item of [
|
||||
{ model: minimax.model("MiniMax-M3"), path: "/anthropic/v1/messages" },
|
||||
{ model: minimax.chat("MiniMax-M3"), path: "/v1/chat/completions" },
|
||||
{ model: minimax.responses("MiniMax-M3"), path: "/v1/responses" },
|
||||
]) {
|
||||
const request = LLM.request({ model: item.model, prompt: "Hello" })
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(item.model.provider).toBe("minimax")
|
||||
expect(item.model.route.providerMetadataKey).toBe("minimax")
|
||||
const url = Endpoint.render(item.model.route.endpoint, { request, body: compiled.body }).toString()
|
||||
expect(url).toBe(`https://api.minimax.io${item.path}`)
|
||||
const headers = yield* item.model.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url,
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
expect(headers.authorization).toBe("Bearer fixture-key")
|
||||
expect(headers["x-api-key"]).toBeUndefined()
|
||||
}
|
||||
}).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { MINIMAX_API_KEY: "fixture-key" } })))),
|
||||
)
|
||||
|
||||
it.effect("honors explicit auth and custom API bases", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = MiniMax.configure({
|
||||
baseURL: "https://gateway.example/anthropic/v1",
|
||||
auth: Auth.header("x-api-key", "gateway-key"),
|
||||
}).model("custom-model")
|
||||
const request = LLM.request({ model, prompt: "Hello" })
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(Endpoint.render(model.route.endpoint, { request, body: compiled.body }).toString()).toBe(
|
||||
"https://gateway.example/anthropic/v1/messages",
|
||||
)
|
||||
expect(model.route.headers?.({ request })).toEqual({ "anthropic-version": "2023-06-01" })
|
||||
const headers = yield* model.route.auth.apply({
|
||||
request,
|
||||
method: "POST",
|
||||
url: "https://gateway.example/anthropic/v1/messages",
|
||||
body: "{}",
|
||||
headers: Headers.empty,
|
||||
})
|
||||
expect(headers["x-api-key"]).toBe("gateway-key")
|
||||
expect(headers.authorization).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps thinking controls native to the selected API", () =>
|
||||
Effect.gen(function* () {
|
||||
const minimax = MiniMax.configure({ apiKey: "fixture" })
|
||||
const messages = yield* compileRequest(
|
||||
LLM.request({ model: minimax.model("MiniMax-M3"), providerOptions: { thinking: { type: "adaptive" } } }),
|
||||
)
|
||||
expect(messages.body.thinking).toEqual({ type: "adaptive" })
|
||||
expect(messages.body.output_config).toBeUndefined()
|
||||
const chat = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: minimax.chat("MiniMax-M3"),
|
||||
generation: { maxTokens: 128 },
|
||||
providerOptions: { thinking: { type: "disabled" }, reasoningSplit: false },
|
||||
}),
|
||||
)
|
||||
expect(chat.body).toMatchObject({
|
||||
thinking: { type: "disabled" },
|
||||
reasoning_split: false,
|
||||
max_completion_tokens: 128,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
expect(chat.body.store).toBeUndefined()
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: minimax.responses("MiniMax-M3"), providerOptions: { reasoningEffort: "minimal" } }),
|
||||
)
|
||||
expect(responses.body.reasoning).toEqual({ effort: "minimal" })
|
||||
expect(responses.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
identifierSegment,
|
||||
inputProperties,
|
||||
inputTypeScript,
|
||||
isEmptyInput,
|
||||
outputTypeScript,
|
||||
} from "./tool-schema.js"
|
||||
import { isNamespace, type Namespace } from "./namespace.js"
|
||||
@@ -328,7 +329,9 @@ const flattenTools = <R>(
|
||||
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
|
||||
path: visible.path,
|
||||
description: visible.tool.description,
|
||||
signature: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
signature: isEmptyInput(visible.tool)
|
||||
? `${toolExpression(visible.path)}(): Promise<${outputTypeScript(visible.tool, true)}>`
|
||||
: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
})
|
||||
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
@@ -522,10 +525,11 @@ export const make = <R>(
|
||||
|
||||
const executeTool = (name: string, tool: Tool<R>, externalArgs: Array<unknown>) =>
|
||||
Effect.gen(function* () {
|
||||
if (externalArgs.length !== 1)
|
||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects exactly one input object.`)
|
||||
const normalized = externalArgs.length === 0 ? [{}] : externalArgs
|
||||
if (normalized.length !== 1)
|
||||
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects at most one input object.`)
|
||||
const input = yield* Effect.try({
|
||||
try: () => decodeToolInput(tool, externalArgs[0]),
|
||||
try: () => decodeToolInput(tool, normalized[0]),
|
||||
catch: (cause) =>
|
||||
new ToolRuntimeError(
|
||||
"InvalidToolInput",
|
||||
|
||||
@@ -261,6 +261,11 @@ export const inputProperties = <R>(tool: Tool<R>): Array<InputProperty> => {
|
||||
export const inputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
isEffectSchema(tool.input) ? toTypeScript(tool.input, false, pretty) : jsonSchemaToTypeScript(tool.input, pretty)
|
||||
|
||||
// Empty object schemas render as `{}` in compact form; anything with properties,
|
||||
// an index signature, or union members renders differently, so equality is a
|
||||
// conservative emptiness test for both Effect and JSON Schema inputs.
|
||||
export const isEmptyInput = <R>(tool: Tool<R>): boolean => inputTypeScript(tool) === "{}"
|
||||
|
||||
export const outputTypeScript = <R>(tool: Tool<R>, pretty = false): string =>
|
||||
tool.output === undefined
|
||||
? "void"
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("dotted tool names", () => {
|
||||
const catalog = runtime.catalog()
|
||||
expect(catalog).toHaveLength(1)
|
||||
expect(catalog[0]?.path).toBe("api.issues.list")
|
||||
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(input:")
|
||||
expect(catalog[0]?.signature).toStartWith("tools.api.issues.list(")
|
||||
})
|
||||
|
||||
test("the advertised dotted path is executable", async () => {
|
||||
@@ -138,10 +138,16 @@ describe("tool input diagnostics", () => {
|
||||
})
|
||||
|
||||
test("a wrong argument count keeps the existing error without a stale-signature hint", async () => {
|
||||
const diagnostic = await failure(runtime, `return await tools.notes.echo()`)
|
||||
const diagnostic = await failure(runtime, `return await tools.notes.echo({}, {})`)
|
||||
expect(diagnostic.kind).toBe("InvalidToolInput")
|
||||
expect(diagnostic.suggestions).toBeUndefined()
|
||||
})
|
||||
|
||||
test("an empty-input tool advertises () and runs with zero arguments", async () => {
|
||||
const empty = CodeMode.make({ tools: { ping: echo("Ping", "pong") } })
|
||||
expect(empty.catalog()[0]?.signature).toBe("tools.ping(): Promise<string>")
|
||||
expect(await value(empty, `return await tools.ping()`)).toBe("pong")
|
||||
})
|
||||
})
|
||||
|
||||
describe("blocked member names on tool paths", () => {
|
||||
|
||||
@@ -9,8 +9,8 @@ export const config = {
|
||||
github: {
|
||||
repoUrl: "https://github.com/anomalyco/opencode",
|
||||
starsFormatted: {
|
||||
compact: "195K",
|
||||
full: "195,000",
|
||||
compact: "205K",
|
||||
full: "205,000",
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -6,6 +6,13 @@ import { AbsolutePath } from "@opencode-ai/schema/schema"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
export const RenameInput = Schema.Struct({
|
||||
sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to rename the current session." }),
|
||||
title: Schema.String.check(Schema.isMinLength(1)).annotate({ description: "New session title." }),
|
||||
})
|
||||
|
||||
const RenameOutput = Schema.Struct({ sessionID: Session.ID, title: Schema.String })
|
||||
|
||||
export const MoveInput = Schema.Struct({
|
||||
sessionID: Schema.optionalKey(Session.ID).annotate({ description: "Omit to move the current session." }),
|
||||
directory: AbsolutePath.check(Schema.isMinLength(1)).annotate({
|
||||
@@ -30,6 +37,26 @@ export const Plugin = {
|
||||
yield* ctx.tool
|
||||
.transform((draft) => {
|
||||
draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." })
|
||||
draft.add({
|
||||
name: "session_rename",
|
||||
description:
|
||||
"Rename a session, or omit sessionID to rename the current session. Use a short, specific title that summarizes the work being done.",
|
||||
input: RenameInput,
|
||||
output: RenameOutput,
|
||||
options: { namespace: "opencode", codemode: true },
|
||||
execute: (input, context) => {
|
||||
const sessionID = input.sessionID ?? context.sessionID
|
||||
const title = input.title.trim()
|
||||
if (!title) return Effect.fail(new ToolFailure({ message: "Session title must not be empty" }))
|
||||
return ctx.session.rename({ sessionID, title }).pipe(
|
||||
Effect.as({
|
||||
output: { sessionID, title },
|
||||
content: `Renamed session ${sessionID} to ${title}.`,
|
||||
}),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to rename session ${sessionID}`, error })),
|
||||
)
|
||||
},
|
||||
})
|
||||
draft.add({
|
||||
name: "session_move",
|
||||
description:
|
||||
|
||||
@@ -113,7 +113,7 @@ const tools = Layer.mock(Tool.Service, {
|
||||
type: "tool",
|
||||
name: "captured.lookup",
|
||||
description: "Captured Code Mode catalog",
|
||||
signature: "tools.captured.lookup(input: {}): Promise<string>",
|
||||
signature: "tools.captured.lookup(): Promise<string>",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -327,7 +327,7 @@ it.effect(
|
||||
)
|
||||
expect(instructionUpdates).toHaveLength(1)
|
||||
expect(instructionUpdates?.[0]).toContain("Changed context")
|
||||
expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(input: {}): Promise<string>")
|
||||
expect(instructionUpdates?.[0]).toContain("tools.captured.lookup(): Promise<string>")
|
||||
expect(userTexts(requests[0])).toEqual(["Existing durable context", "Summarize privately"])
|
||||
expect(
|
||||
requests[0]?.messages.flatMap((message) =>
|
||||
|
||||
@@ -10,7 +10,7 @@ export type HeaderLink = { href: string; label: string }
|
||||
export const githubLink = {
|
||||
href: "https://github.com/anomalyco/opencode",
|
||||
apiHref: "https://api.github.com/repos/anomalyco/opencode",
|
||||
fallbackStars: "195K",
|
||||
fallbackStars: "205K",
|
||||
}
|
||||
export const themePreferences = ["dark", "light", "system"] as const
|
||||
export const themeStorageKey = "opencode:stats-theme"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
|
||||
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client"
|
||||
import path from "path"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import type { RGBA } from "@opentui/core"
|
||||
import { dialogWidth, useDialog } from "../ui/dialog"
|
||||
import { DialogSelect, dialogSelectContentWidth } from "../ui/dialog-select"
|
||||
import { DialogSelect, dialogSelectContentWidth, type DialogSelectRef } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
import { useData } from "../context/data"
|
||||
import { useClient } from "../context/client"
|
||||
@@ -15,6 +16,8 @@ import { Locale } from "../util/locale"
|
||||
import { abbreviateHome } from "../runtime"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { truncateFilePath } from "../ui/file-path"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback"
|
||||
import { Spinner } from "./spinner"
|
||||
@@ -23,7 +26,11 @@ import { projectName } from "../util/project"
|
||||
const RECENT_LIMIT = 8
|
||||
export const DialogOpenKey = Symbol("DialogOpen")
|
||||
|
||||
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
|
||||
type OpenTarget =
|
||||
| { type: "session"; sessionID: string }
|
||||
| { type: "project"; directory: string; projectID?: string }
|
||||
| { type: "browse"; directory: string }
|
||||
| { type: "new"; projectID: string }
|
||||
|
||||
export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: SessionInfo[]) => void }) {
|
||||
const dialog = useDialog()
|
||||
@@ -32,6 +39,7 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
const client = useClient()
|
||||
const location = useLocation()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const toast = useToast()
|
||||
const themes = useThemes()
|
||||
const theme = useTheme("elevated")
|
||||
const mode = themes.mode
|
||||
@@ -75,6 +83,36 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
() => false,
|
||||
),
|
||||
)
|
||||
const [selected, setSelected] = createSignal<OpenTarget>()
|
||||
const [directory, setDirectory] = createSignal<string>()
|
||||
const [projectID, setProjectID] = createSignal<string>()
|
||||
let select: DialogSelectRef<OpenTarget> | undefined
|
||||
function browse(next?: string) {
|
||||
select?.clearFilter()
|
||||
setSelectionMoved(false)
|
||||
setSelected(undefined)
|
||||
setProjectID(undefined)
|
||||
setDirectory(next)
|
||||
}
|
||||
const [worktrees] = createResource(projectID, (projectID) =>
|
||||
client.api.worktree
|
||||
.list({
|
||||
location: {
|
||||
directory: data.project.get(projectID)!.canonical,
|
||||
workspace: location.ref?.workspaceID ?? data.location.default().workspaceID,
|
||||
},
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
toast.show({ title: "Loading worktrees failed", message: errorMessage(error), variant: "error" })
|
||||
return []
|
||||
}),
|
||||
)
|
||||
const [entries] = createResource(directory, (directory) =>
|
||||
client.api.file
|
||||
.list({ location: { directory, workspace: location.ref?.workspaceID ?? data.location.default().workspaceID } })
|
||||
.then((result) => result.data.filter((entry) => entry.type === "directory"))
|
||||
.catch(() => undefined),
|
||||
)
|
||||
|
||||
const [matched] = createResource(
|
||||
() => {
|
||||
@@ -119,15 +157,20 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
const sessionOptions = recent.map((session) => {
|
||||
const project = data.project.get(session.projectID)
|
||||
const name = projectName(project)
|
||||
const basename = path.basename(session.location.directory)
|
||||
const label =
|
||||
name && session.location.directory !== project?.canonical && name.toLowerCase() !== basename.toLowerCase()
|
||||
? `${name} · ${basename}`
|
||||
: name || basename
|
||||
const running =
|
||||
data.session.status(session.id) === "running" ||
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
return {
|
||||
title: withTimestampedFallback(session),
|
||||
searchText: session.id,
|
||||
searchText: `${session.id} ${session.location.directory}`,
|
||||
value: { type: "session", sessionID: session.id } as OpenTarget,
|
||||
category: "Sessions",
|
||||
footer: `${name ? `${Locale.truncate(name, 20)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
footer: `${label ? `${Locale.truncate(label, 30)} · ` : ""}${timeAgo(session.time.updated)}`,
|
||||
onSelect: () => location.set(session.location),
|
||||
gutter: running
|
||||
? (color: RGBA) => <Spinner color={color} />
|
||||
@@ -137,28 +180,46 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
}
|
||||
})
|
||||
|
||||
const current = location.current?.project
|
||||
const current = location.ref?.directory ?? location.current?.directory
|
||||
const seen = new Set<string>()
|
||||
const projectOptions = data.project
|
||||
.list()
|
||||
.filter((project) => {
|
||||
if (project.canonical === "/" || seen.has(project.canonical)) return false
|
||||
seen.add(project.canonical)
|
||||
const projectOptions = [
|
||||
...data.project
|
||||
.list()
|
||||
.flatMap((project) => [project.canonical, ...project.sandboxes].map((directory) => ({ directory, project }))),
|
||||
...sessions().map((session) => ({
|
||||
directory: session.location.directory,
|
||||
project: data.project.get(session.projectID),
|
||||
})),
|
||||
]
|
||||
.filter((item) => {
|
||||
if (item.directory === "/" || seen.has(item.directory)) return false
|
||||
seen.add(item.directory)
|
||||
return true
|
||||
})
|
||||
.map((project) => {
|
||||
const title = projectName(project) ?? project.canonical
|
||||
const footer = abbreviateHome(project.canonical, paths.home)
|
||||
.map((item) => {
|
||||
const title =
|
||||
item.directory === item.project?.canonical
|
||||
? (projectName(item.project) ?? path.basename(item.directory))
|
||||
: path.basename(item.directory)
|
||||
const footer = abbreviateHome(item.directory, paths.home)
|
||||
const git = item.project?.vcs === "git"
|
||||
const width =
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) - stringWidth(title)
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) -
|
||||
stringWidth(title) -
|
||||
(git ? 2 : 0)
|
||||
return {
|
||||
title,
|
||||
footer: truncateFilePath(footer, width),
|
||||
searchText: footer,
|
||||
value: { type: "project", directory: project.canonical } as OpenTarget,
|
||||
footer: `${truncateFilePath(footer, width)}${git ? " →" : ""}`,
|
||||
searchText: `${footer} ${projectName(item.project) ?? ""}`,
|
||||
value: {
|
||||
type: "project",
|
||||
directory: item.directory,
|
||||
...(git ? { projectID: item.project!.id } : {}),
|
||||
} as OpenTarget,
|
||||
category: "Projects",
|
||||
gutter:
|
||||
project.canonical === current?.canonical
|
||||
item.directory === current ||
|
||||
(item.directory === location.current?.project.canonical && (!current || !seen.has(current)))
|
||||
? () => <text fg={theme.text.formfield.selected}>●</text>
|
||||
: undefined,
|
||||
}
|
||||
@@ -167,16 +228,119 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
return [...sessionOptions, ...projectOptions]
|
||||
})
|
||||
|
||||
const worktreeOptions = createMemo(() => {
|
||||
const id = projectID()
|
||||
if (!id) return []
|
||||
const project = data.project.get(id)
|
||||
if (!project) return []
|
||||
const current = location.ref?.directory ?? location.current?.directory
|
||||
const directories = [project.canonical, ...(worktrees() ?? []).map((worktree) => worktree.directory)]
|
||||
const width = Math.max(
|
||||
0,
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) -
|
||||
Math.max(
|
||||
...directories.map((directory) =>
|
||||
stringWidth(
|
||||
directory === project.canonical
|
||||
? (projectName(project) ?? path.basename(directory))
|
||||
: path.basename(directory),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
return [
|
||||
...directories
|
||||
.filter((directory, index) => directories.indexOf(directory) === index)
|
||||
.toSorted((a, b) => {
|
||||
if (a === project.canonical) return -1
|
||||
if (b === project.canonical) return 1
|
||||
if (a === current) return -1
|
||||
if (b === current) return 1
|
||||
return 0
|
||||
})
|
||||
.map((directory) => {
|
||||
const title =
|
||||
directory === project.canonical
|
||||
? (projectName(project) ?? path.basename(directory))
|
||||
: path.basename(directory)
|
||||
const footer = truncateFilePath(abbreviateHome(directory, paths.home), width)
|
||||
return {
|
||||
title,
|
||||
footer: footer + " ".repeat(Math.max(0, width - stringWidth(footer))),
|
||||
value: { type: "project", directory } as OpenTarget,
|
||||
category: "Worktrees",
|
||||
gutter: directory === current ? () => <text fg={theme.text.formfield.selected}>●</text> : undefined,
|
||||
}
|
||||
}),
|
||||
{
|
||||
title: "+ New worktree",
|
||||
value: { type: "new", projectID: id } as OpenTarget,
|
||||
category: "Worktrees",
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const directoryOptions = createMemo(() => {
|
||||
const current = directory()
|
||||
if (!current) return []
|
||||
return [
|
||||
{
|
||||
title: "Open this directory",
|
||||
footer: truncateFilePath(
|
||||
abbreviateHome(current, paths.home),
|
||||
dialogSelectContentWidth(Math.min(dialogWidth("large"), dimensions().width - 2)) -
|
||||
stringWidth("Open this directory"),
|
||||
),
|
||||
value: { type: "project", directory: current } as OpenTarget,
|
||||
category: "Current",
|
||||
},
|
||||
...(path.dirname(current) !== current
|
||||
? [
|
||||
{
|
||||
title: "..",
|
||||
value: { type: "browse", directory: path.dirname(current) } as OpenTarget,
|
||||
category: "Current",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(entries() ?? [])
|
||||
.toSorted((a, b) => a.path.localeCompare(b.path))
|
||||
.map((entry) => ({
|
||||
title: path.basename(entry.path),
|
||||
value: { type: "browse", directory: path.resolve(current, entry.path) } as OpenTarget,
|
||||
category: "Directories",
|
||||
})),
|
||||
]
|
||||
})
|
||||
|
||||
return (
|
||||
<DialogSelect
|
||||
title="Open"
|
||||
placeholder="Search sessions and projects…"
|
||||
options={options()}
|
||||
current={currentSessionID() ? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget) : undefined}
|
||||
focusCurrent={false}
|
||||
ref={(value) => (select = value)}
|
||||
title={projectID() ? "Worktrees" : "Open"}
|
||||
placeholder={
|
||||
directory()
|
||||
? abbreviateHome(directory()!, paths.home)
|
||||
: projectID()
|
||||
? "Search worktrees…"
|
||||
: "Search sessions and projects…"
|
||||
}
|
||||
options={directory() ? directoryOptions() : projectID() ? worktreeOptions() : options()}
|
||||
current={
|
||||
directory()
|
||||
? ({ type: "project", directory: directory()! } as OpenTarget)
|
||||
: projectID() && (location.ref?.directory ?? location.current?.directory)
|
||||
? ({ type: "project", directory: (location.ref?.directory ?? location.current?.directory)! } as OpenTarget)
|
||||
: currentSessionID()
|
||||
? ({ type: "session", sessionID: currentSessionID()! } as OpenTarget)
|
||||
: undefined
|
||||
}
|
||||
focusCurrent={Boolean(directory() || projectID())}
|
||||
sectionNavigation={true}
|
||||
preserveSelection={selectionMoved()}
|
||||
onMove={() => setSelectionMoved(true)}
|
||||
onMove={(option) => {
|
||||
setSelectionMoved(true)
|
||||
setSelected(option.value)
|
||||
}}
|
||||
onFilter={setFilter}
|
||||
emptyView={
|
||||
<Show when={!recent.loading && !projects.loading}>
|
||||
@@ -186,36 +350,127 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
|
||||
</Show>
|
||||
}
|
||||
footer={
|
||||
<box>
|
||||
<Show when={recent.loading || projects.loading}>
|
||||
<Spinner color={theme.text.subdued}>Refreshing sessions and projects…</Spinner>
|
||||
</Show>
|
||||
<Show when={recent() === false || projects() === false}>
|
||||
<text fg={theme.text.feedback.error.default}>
|
||||
Could not refresh{" "}
|
||||
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={recent.loading || projects.loading || recent() === false || projects() === false}>
|
||||
<box>
|
||||
<Show when={recent.loading || projects.loading}>
|
||||
<Spinner color={theme.text.subdued}>Refreshing sessions and projects…</Spinner>
|
||||
</Show>
|
||||
<Show when={recent() === false || projects() === false}>
|
||||
<text fg={theme.text.feedback.error.default}>
|
||||
Could not refresh{" "}
|
||||
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
}
|
||||
bindings={[
|
||||
{
|
||||
bind: "ctrl+o",
|
||||
title: directory() ? "Return to projects" : "Browse directories",
|
||||
group: "Dialog",
|
||||
run: () =>
|
||||
browse(directory() ? undefined : (location.ref?.directory ?? location.current?.directory ?? paths.cwd)),
|
||||
},
|
||||
...(!directory() && !projectID()
|
||||
? [
|
||||
{
|
||||
bind: "right",
|
||||
title: "Show project worktrees",
|
||||
group: "Dialog",
|
||||
run: () => {
|
||||
const target = selected() ?? select?.filtered[0]?.value
|
||||
if (target?.type !== "project" || !target.projectID) return
|
||||
select?.clearFilter()
|
||||
setSelectionMoved(false)
|
||||
setSelected(undefined)
|
||||
setProjectID(target.projectID)
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(projectID()
|
||||
? [
|
||||
{
|
||||
bind: "left",
|
||||
title: "Return to projects",
|
||||
group: "Dialog",
|
||||
run: () => browse(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(directory() && path.dirname(directory()!) !== directory()
|
||||
? [
|
||||
{
|
||||
bind: "ctrl+u",
|
||||
title: "Browse parent directory",
|
||||
group: "Dialog",
|
||||
run: () => browse(path.dirname(directory()!)),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
footerHints={[
|
||||
...(projectID() ? [{ title: "back", label: "←" }] : []),
|
||||
{ title: directory() ? "back" : "browse directories", label: "ctrl+o" },
|
||||
...(directory() && path.dirname(directory()!) !== directory() ? [{ title: "parent", label: "ctrl+u" }] : []),
|
||||
]}
|
||||
noMatchView={
|
||||
<box paddingLeft={4} paddingRight={4}>
|
||||
<text fg={theme.text.subdued}>
|
||||
{recent.loading || projects.loading || matched.loading
|
||||
? "Searching sessions and projects…"
|
||||
: shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
{directory()
|
||||
? entries.loading
|
||||
? "Loading directories…"
|
||||
: "No matching directories"
|
||||
: recent.loading || projects.loading || matched.loading
|
||||
? "Searching sessions and projects…"
|
||||
: shortcuts.get("session.list")
|
||||
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
|
||||
: "No matches"}
|
||||
</text>
|
||||
</box>
|
||||
}
|
||||
onSelect={(option) => {
|
||||
if (option.value.type === "browse") {
|
||||
browse(option.value.directory)
|
||||
return
|
||||
}
|
||||
if (option.value.type === "new") {
|
||||
const id = option.value.projectID
|
||||
void client.api.worktree
|
||||
.create({
|
||||
location: {
|
||||
directory: data.project.get(id)!.canonical,
|
||||
workspace: location.ref?.workspaceID ?? data.location.default().workspaceID,
|
||||
},
|
||||
strategy: "git",
|
||||
directory: path.join(paths.worktree, id.slice(0, 6)),
|
||||
})
|
||||
.then((created) => {
|
||||
const target = {
|
||||
directory: created.directory,
|
||||
...(location.ref?.workspaceID ? { workspaceID: location.ref.workspaceID } : {}),
|
||||
}
|
||||
dialog.clear()
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
toast.show({ title: "Creating worktree failed", message: errorMessage(error), variant: "error" }),
|
||||
)
|
||||
return
|
||||
}
|
||||
dialog.clear()
|
||||
if (option.value.type === "session") {
|
||||
route.navigate({ type: "session", sessionID: option.value.sessionID })
|
||||
return
|
||||
}
|
||||
const target = { directory: option.value.directory }
|
||||
const target = {
|
||||
directory: option.value.directory,
|
||||
...((directory() || projectID()) && location.ref?.workspaceID
|
||||
? { workspaceID: location.ref.workspaceID }
|
||||
: {}),
|
||||
}
|
||||
route.navigate({ type: "home", location: target })
|
||||
location.set(target)
|
||||
}}
|
||||
|
||||
@@ -93,6 +93,7 @@ export function dialogSelectContentWidth(dialogWidth: number) {
|
||||
export type DialogSelectRef<T> = {
|
||||
filter: string
|
||||
filtered: DialogSelectOption<T>[]
|
||||
clearFilter(): void
|
||||
moveTo(value: T): void
|
||||
}
|
||||
|
||||
@@ -514,6 +515,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
get filtered() {
|
||||
return filtered()
|
||||
},
|
||||
clearFilter() {
|
||||
input.value = ""
|
||||
batch(() => {
|
||||
setStore("filter", "")
|
||||
props.onFilter?.("")
|
||||
})
|
||||
},
|
||||
moveTo(value) {
|
||||
const index = flat().findIndex((option) => isDeepEqual(option.value, value))
|
||||
if (index >= 0) moveTo(index, true)
|
||||
@@ -774,7 +782,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
</box>
|
||||
<Show when={props.footer || visibleActions().length} fallback={<box flexShrink={0} />}>
|
||||
<box paddingRight={2} paddingLeft={4} flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box flexDirection={dimensions().width < 60 ? "column" : "row"} gap={dimensions().width < 60 ? 0 : 2}>
|
||||
{props.footer}
|
||||
<For each={left()}>{(item) => <FooterAction item={item} />}</For>
|
||||
</box>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { once } from "node:events"
|
||||
import { CliRenderEvents, TextAttributes } from "@opentui/core"
|
||||
import path from "path"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import type { SessionInfo } from "@opencode-ai/client"
|
||||
@@ -134,6 +135,416 @@ test("shows the current project and opens its root", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("includes unique sandbox and recent session directories, including global projects", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_current",
|
||||
canonical: "/tmp/opencode/project",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: ["/tmp/opencode/feature-branch"],
|
||||
},
|
||||
{
|
||||
id: "global",
|
||||
canonical: "/",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_global",
|
||||
projectID: "global",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 3 },
|
||||
title: "Standalone session",
|
||||
location: { directory: "/tmp/standalone-notes" },
|
||||
},
|
||||
{
|
||||
id: "ses_worktree",
|
||||
projectID: "proj_current",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Worktree session",
|
||||
location: { directory: "/tmp/opencode/feature-branch" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame(
|
||||
(value) => value.includes("Standalone session") && value.includes("feature-branch") && value.includes("Projects"),
|
||||
)
|
||||
expect(frame).toContain("standalone-notes")
|
||||
expect(frame).toContain("OpenCode · feature-branch")
|
||||
expect(frame.match(/\/tmp\/opencode\/feature-branch/g)).toHaveLength(1)
|
||||
|
||||
await fixture.app.mockInput.typeText("standalone-notes")
|
||||
await fixture.app.waitForFrame((value) => value.includes("standalone-notes"))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: "/tmp/standalone-notes" } })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows nested Git session directories as projects and in their session footer", async () => {
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_current",
|
||||
canonical: "/tmp/opencode/project",
|
||||
name: "OpenCode",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
if (url.pathname !== "/api/session") return undefined
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_dashboard",
|
||||
projectID: "proj_current",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Improve dashboard",
|
||||
location: { directory: "/tmp/opencode/project/packages/dashboard" },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame(
|
||||
(value) => value.includes("Improve dashboard") && value.includes("browse directories"),
|
||||
)
|
||||
expect(frame).toContain("OpenCode · dashboard")
|
||||
expect(frame).toContain("/tmp/opencode/project/packages/dashboard")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("loads Git worktrees only when drilling into a project or its associated directory", async () => {
|
||||
const root = path.resolve("/tmp/opencode/project")
|
||||
const current = path.resolve("/tmp/opencode/current-branch")
|
||||
const other = path.resolve("/tmp/opencode/other-branch")
|
||||
const workspaceID = "ws_worktree"
|
||||
let requests = 0
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: "proj_git",
|
||||
canonical: root,
|
||||
name: "OpenCode",
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [current],
|
||||
},
|
||||
])
|
||||
if (url.pathname === "/api/location")
|
||||
return json({
|
||||
directory: current,
|
||||
workspaceID,
|
||||
project: { id: "proj_git", directory: current, canonical: root },
|
||||
})
|
||||
if (url.pathname !== "/api/worktree") return undefined
|
||||
expect(url.searchParams.get("location[directory]")).toBe(root)
|
||||
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
|
||||
requests++
|
||||
return json([{ directory: other, strategy: "git" }, { directory: root }, { directory: current, strategy: "git" }])
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: current, workspaceID })
|
||||
location.set({ directory: current, workspaceID })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
const projects = await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("OpenCode") && frame.includes("current-branch") && frame.includes("→"),
|
||||
)
|
||||
expect(projects).not.toContain("Browse directories")
|
||||
expect(requests).toBe(0)
|
||||
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
const worktrees = await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("other-branch") && frame.includes("+ New worktree"),
|
||||
)
|
||||
expect(requests).toBe(1)
|
||||
expect(worktrees).toContain("Worktrees")
|
||||
expect(worktrees).toContain("●")
|
||||
expect(worktrees.indexOf("OpenCode")).toBeLessThan(worktrees.indexOf("current-branch"))
|
||||
expect(worktrees.indexOf("current-branch")).toBeLessThan(worktrees.indexOf("other-branch"))
|
||||
|
||||
fixture.app.mockInput.pressArrow("left")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
|
||||
await fixture.app.mockInput.typeText("current-branch")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("current-branch") && !frame.includes("OpenCode"))
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("other-branch") && frame.includes("+ New worktree"))
|
||||
expect(requests).toBe(2)
|
||||
|
||||
await fixture.app.mockInput.typeText("other-branch")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: other, workspaceID } })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not show or trigger worktree navigation for non-Git and global directories", async () => {
|
||||
const root = path.resolve("/tmp/plain-project")
|
||||
const standalone = path.resolve("/tmp/standalone-notes")
|
||||
let requests = 0
|
||||
const fixture = await renderOpen((url) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{ id: "proj_plain", canonical: root, name: "Plain project", time: { created: 1, updated: 2 }, sandboxes: [] },
|
||||
{ id: "global", canonical: "/", time: { created: 1, updated: 1 }, sandboxes: [] },
|
||||
])
|
||||
if (url.pathname === "/api/session")
|
||||
return json({
|
||||
data: [
|
||||
{
|
||||
id: "ses_global",
|
||||
projectID: "global",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 2 },
|
||||
title: "Standalone session",
|
||||
location: { directory: standalone },
|
||||
},
|
||||
],
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname !== "/api/worktree") return undefined
|
||||
requests++
|
||||
return json([])
|
||||
})
|
||||
|
||||
try {
|
||||
const frame = await fixture.app.waitForFrame(
|
||||
(value) => value.includes("Plain project") && value.includes("standalone-notes"),
|
||||
)
|
||||
expect(frame).not.toContain("→")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
await fixture.app.renderOnce()
|
||||
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
|
||||
expect(requests).toBe(0)
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("creates an unnamed Git worktree and opens it in the current workspace", async () => {
|
||||
const projectID = "proj_git_create"
|
||||
const root = path.resolve("/tmp/opencode/project")
|
||||
const created = path.resolve("/tmp/opencode/created-branch")
|
||||
const workspaceID = "ws_create"
|
||||
let payload: unknown
|
||||
const fixture = await renderOpen(
|
||||
async (url, request) => {
|
||||
if (url.pathname === "/api/project")
|
||||
return json([
|
||||
{
|
||||
id: projectID,
|
||||
canonical: root,
|
||||
name: "OpenCode",
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 2 },
|
||||
sandboxes: [],
|
||||
},
|
||||
])
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory: root, workspaceID, project: { id: projectID, directory: root, canonical: root } })
|
||||
if (url.pathname !== "/api/worktree") return undefined
|
||||
expect(url.searchParams.get("location[directory]")).toBe(root)
|
||||
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
|
||||
if (request.method === "GET") return json([{ directory: root }])
|
||||
payload = await request.json()
|
||||
return json({ directory: created })
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: root, workspaceID })
|
||||
location.set({ directory: root, workspaceID })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("OpenCode") && frame.includes("→"))
|
||||
fixture.app.mockInput.pressArrow("right")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("+ New worktree"))
|
||||
fixture.app.mockInput.pressArrow("down")
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
|
||||
expect(payload).toEqual({
|
||||
strategy: "git",
|
||||
directory: path.join("/tmp/opencode", projectID.slice(0, 6)),
|
||||
})
|
||||
expect(fixture.route.data).toEqual({ type: "home", location: { directory: created, workspaceID } })
|
||||
expect(fixture.location.ref).toEqual({ directory: created, workspaceID })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps directory browsing in the footer and clears its search when toggling the browser", async () => {
|
||||
const root = path.resolve(
|
||||
"/private/var/folders/very-long-temporary-directory/opencode-drive/run-6462634d-8106-4652-ab87-e7e3cf5177ad/files",
|
||||
)
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory: root, project: { id: "proj_current", directory: root, canonical: root } })
|
||||
if (url.pathname !== "/api/fs/list") return undefined
|
||||
return json({
|
||||
location: { directory: root, project: { id: "proj_current", directory: root, canonical: root } },
|
||||
data: [{ path: "packages", type: "directory" }],
|
||||
})
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: root })
|
||||
location.set({ directory: root })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
const initial = await fixture.app.waitForFrame((frame) => frame.includes("browse directories"))
|
||||
expect(initial).not.toContain("Browse directories")
|
||||
await fixture.app.mockInput.typeText("missing")
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("No matches"))
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
const browser = await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("Open this directory") && frame.includes("packages"),
|
||||
)
|
||||
expect(browser).not.toContain("No matching directories")
|
||||
|
||||
await fixture.app.mockInput.typeText("packages")
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
const projects = await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
|
||||
expect(projects).toContain("browse directories")
|
||||
expect(projects).not.toContain("Browse directories")
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("browses from the current directory and opens an arbitrary child directory", async () => {
|
||||
const root = path.resolve("/tmp/opencode/project")
|
||||
const packages = path.resolve(root, "packages")
|
||||
const untracked = path.resolve(packages, "untracked")
|
||||
const workspaceID = "ws_browser"
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory: root, workspaceID, project: { id: "proj_current", directory: root, canonical: root } })
|
||||
if (url.pathname !== "/api/fs/list") return undefined
|
||||
expect(url.searchParams.get("location[workspace]")).toBe(workspaceID)
|
||||
const current = url.searchParams.get("location[directory]")
|
||||
return json({
|
||||
location: {
|
||||
directory: current,
|
||||
workspaceID,
|
||||
project: { id: "proj_current", directory: root, canonical: root },
|
||||
},
|
||||
data:
|
||||
current === root
|
||||
? [
|
||||
{ path: "packages", type: "directory" },
|
||||
{ path: "README.md", type: "file" },
|
||||
]
|
||||
: current && path.normalize(current) === path.normalize(packages)
|
||||
? [{ path: "untracked", type: "directory" }]
|
||||
: [],
|
||||
})
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: root, workspaceID })
|
||||
location.set({ directory: root, workspaceID })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("browse"))
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
const rootFrame = await fixture.app.waitForFrame(
|
||||
(frame) => frame.includes("Open this directory") && frame.includes("packages"),
|
||||
)
|
||||
expect(rootFrame).not.toContain("README.md")
|
||||
|
||||
fixture.app.mockInput.pressArrow("down", { meta: true })
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("untracked"))
|
||||
|
||||
fixture.app.mockInput.pressArrow("down", { meta: true })
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitForFrame((frame) => frame.includes(untracked))
|
||||
fixture.app.mockInput.pressEnter()
|
||||
await fixture.app.waitFor(() => fixture.route.data.type === "home")
|
||||
expect(fixture.route.data).toEqual({
|
||||
type: "home",
|
||||
location: { directory: untracked, workspaceID },
|
||||
})
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("navigates to the parent directory and returns to the project picker", async () => {
|
||||
const root = path.resolve("/tmp/opencode/project")
|
||||
const parent = path.dirname(root)
|
||||
const fixture = await renderOpen(
|
||||
(url) => {
|
||||
if (url.pathname === "/api/location")
|
||||
return json({ directory: root, project: { id: "proj_current", directory: root, canonical: root } })
|
||||
if (url.pathname !== "/api/fs/list") return undefined
|
||||
const current = url.searchParams.get("location[directory]")
|
||||
return json({
|
||||
location: { directory: current, project: { id: "proj_current", directory: root, canonical: root } },
|
||||
data:
|
||||
current && path.normalize(current) === path.normalize(parent) ? [{ path: "sibling", type: "directory" }] : [],
|
||||
})
|
||||
},
|
||||
async ({ data, location }) => {
|
||||
await data.location.sync({ directory: root })
|
||||
location.set({ directory: root })
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("browse"))
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Open this directory"))
|
||||
fixture.app.mockInput.pressKey("u", { ctrl: true })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("sibling") && frame.includes(parent))
|
||||
fixture.app.mockInput.pressKey("o", { ctrl: true })
|
||||
await fixture.app.waitForFrame((frame) => frame.includes("Search sessions and projects"))
|
||||
expect(fixture.location.ref).toEqual({ directory: root })
|
||||
} finally {
|
||||
await fixture.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("shows projects while sessions refresh and preserves the selected project", async () => {
|
||||
let resolveSessions!: (response: Response) => void
|
||||
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
|
||||
|
||||
Reference in New Issue
Block a user