feat(ai): add Moonshot provider (#47851)

This commit is contained in:
Aiden Cline
2026-09-07 19:50:55 -05:00
committed by GitHub
parent c1c6ab593d
commit 2ac698d65a
46 changed files with 2119 additions and 0 deletions
+61
View File
@@ -29,6 +29,67 @@ 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.
## Moonshot
Moonshot defaults to Chat Completions, with Messages and Responses selectors for Kimi K3:
```ts
import { LLM } from "@opencode/ai"
import { Moonshot } from "@opencode/ai/providers"
const moonshot = Moonshot.configure({ apiKey: process.env.MOONSHOT_API_KEY })
const request = LLM.request({
model: moonshot.model("kimi-k3"), // also moonshot.chat("kimi-k3")
prompt: "Explain the tradeoffs in this design.",
providerOptions: { reasoningEffort: "high" },
})
const messages = LLM.request({
model: moonshot.messages("kimi-k3"),
prompt: "Explain the tradeoffs in this design.",
providerOptions: { effort: "high" },
})
const responses = LLM.request({
model: moonshot.responses("kimi-k3"),
prompt: "Explain the tradeoffs in this design.",
providerOptions: { reasoningEffort: "high" },
})
```
When `apiKey` is omitted, authentication reads `MOONSHOT_API_KEY`, then `MOONSHOTAI_API_KEY`.
Chat and Responses use `https://api.moonshot.ai/v1`; Messages uses
`https://api.moonshot.ai/anthropic/v1`. `baseURL` overrides the selected API's complete base,
including the version prefix, for regional endpoints or gateways. Each endpoint requires its own valid credentials.
All three routes use HTTP/SSE.
Reasoning options stay native to the selected API and model:
| Model/API | Provider options |
| --------------------------- | --------------------------------------------------------------------------------------- |
| K3 Chat / Responses | `reasoningEffort: "low" \| "high" \| "max"`; default is `max` |
| K3 Messages | `effort: "low" \| "high" \| "max"`; default is `max` |
| K2.6 Chat | `thinking: { type: "enabled" \| "disabled", keep?: "all" \| null }`; default is enabled |
| K2.7 Code / high-speed Chat | Omit `thinking` to use always-on, preserved reasoning |
Omitting options preserves the model's defaults. K3 uses effort rather than the K2.x `thinking`
parameter. Known effort values have autocomplete while future strings remain accepted.
For K2.6, `thinking.keep: "all"` enables preservation of reasoning across user messages.
K3 and both K2.7 Code variants always preserve reasoning. Continue with the returned
`response.message` and matching tool results so reasoning content and any Messages signatures are retained.
Leave sampling options such as `temperature` unset to use these models' fixed defaults.
The recorded suite covers all three K3 APIs, default and explicit efforts, K2.6 thinking modes,
both K2.7 Code variants, generated tool loops with a subsequent user follow-up, required/disabled
tool choice, image-byte input, and native structured output through `http.body` overlays.
K3 Chat and Messages accept required and disabled tool choice. Responses supports automatic tool
choice only; explicit `required` and `none` produce a provider `InvalidRequest` error, also covered by recordings.
The provider targets the Moonshot Open Platform; Kimi Code is a separate product and endpoint.
Package entrypoints are `@opencode/ai/providers/moonshot`, `moonshot/chat`, `moonshot/messages`,
and `moonshot/responses`; each exports `model(modelID, settings)`.
## MiniMax
MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
+1
View File
@@ -19,6 +19,7 @@ export * as Groq from "./groq.js"
export * as Meta from "./meta.js"
export * as MiniMax from "./minimax.js"
export * as Mistral from "./mistral.js"
export * as Moonshot from "./moonshot.js"
export * as OpenAI from "./openai.js"
export * as OpenAICompatible from "./openai-compatible.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
+145
View File
@@ -0,0 +1,145 @@
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("moonshotai")
export type ReasoningEffort = "low" | "high" | "max" | (string & {})
export type ChatOptionsInput = {
/** K3 always reasons; omitted effort uses the model's default. */
readonly reasoningEffort?: ReasoningEffort
/** K2.6 supports disabling thinking; K2.7 Code always thinks and preserves reasoning. */
readonly thinking?: {
readonly type: "enabled" | "disabled" | (string & {})
readonly keep?: "all" | (string & {}) | null
}
}
export type MessagesOptionsInput = {
readonly effort?: ReasoningEffort
readonly metadata?: AnthropicMessages.OptionsInput["metadata"]
}
export type ResponsesOptionsInput = {
readonly reasoningEffort?: ReasoningEffort
readonly safetyIdentifier?: string
}
export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
/** Overrides the selected API's base URL, including its version prefix. */
readonly baseURL?: string
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
}
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
readonly apiKey?: string
readonly baseURL?: string
readonly providerOptions?: Options
}
const ChatOptions = Schema.Struct({
reasoningEffort: Schema.optional(Schema.String),
thinking: Schema.optional(
Schema.Struct({ type: Schema.String, keep: Schema.optional(Schema.NullOr(Schema.String)) }),
),
})
const chatProtocol = Protocol.make({
id: "moonshot-chat",
body: {
schema: Schema.Struct({ ...OpenAIChat.bodyFields, thinking: ChatOptions.fields.thinking }),
from: Effect.fn("Moonshot.chatFromRequest")(function* (request: LLMRequest) {
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(ChatOptions))(
request.providerOptions ?? {},
)
return { ...(yield* OpenAIChat.protocol.body.from(request)), thinking: options.thinking }
}),
},
stream: OpenAIChat.protocol.stream,
})
const chatRoute = Route.make({
id: "moonshot-chat",
provider: id,
providerMetadataKey: "moonshot",
protocol: chatProtocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.moonshot.ai/v1" }),
framing: OpenAIChat.framing,
})
const messagesRoute = Route.make({
id: "moonshot-messages",
provider: id,
providerMetadataKey: "moonshot",
protocol: AnthropicMessages.protocol,
endpoint: Endpoint.path("/messages", { baseURL: "https://api.moonshot.ai/anthropic/v1" }),
framing: AnthropicMessages.framing,
})
const responsesRoute = Route.make({
id: "moonshot-responses",
provider: id,
providerMetadataKey: "moonshot",
protocol: OpenResponses.protocol,
endpoint: Endpoint.path("/responses", { baseURL: "https://api.moonshot.ai/v1" }),
framing: Framing.sse,
})
export const routes = [chatRoute, messagesRoute, 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, ["MOONSHOT_API_KEY", "MOONSHOTAI_API_KEY"]),
}
const chat = (modelID: string | ModelID) =>
chatRoute.with(defaults).model<ChatOptionsInput>({
id: modelID,
compatibility: {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
toolSchema: "moonshot",
reasoningField: "reasoning_content",
},
})
const messages = (modelID: string | ModelID) =>
messagesRoute.with(defaults).model<MessagesOptionsInput>({
id: modelID,
compatibility: { requireSignature: false, toolSchema: "moonshot" },
})
const responses = (modelID: string | ModelID) =>
responsesRoute
.with(defaults)
.model<ResponsesOptionsInput>({ id: modelID, compatibility: { toolSchema: "moonshot" } })
return { id, model: chat, chat, messages, responses, configure }
}
export const provider = configure()
export const chat = provider.chat
export const messages = provider.messages
export const responses = provider.responses
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["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 * as Moonshot from "./moonshot.js"
@@ -0,0 +1 @@
export { model, type Settings } from "../moonshot.js"
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { Moonshot } from "../moonshot.js"
export type Settings = Moonshot.Settings<Moonshot.MessagesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.MessagesOptionsInput>["model"] = (
modelID,
settings,
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).messages(modelID)
@@ -0,0 +1,16 @@
import type { ProviderPackage } from "../../provider-package.js"
import { Moonshot } from "../moonshot.js"
export type Settings = Moonshot.Settings<Moonshot.ResponsesOptionsInput>
export const model: ProviderPackage.Definition<Settings, Moonshot.ResponsesOptionsInput>["model"] = (
modelID,
settings,
) =>
Moonshot.configure({
apiKey: settings.apiKey,
baseURL: settings.baseURL,
headers: settings.headers,
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
providerOptions: settings.providerOptions,
}).responses(modelID)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"version": 1,
"metadata": {
"model": "kimi-k2.6",
"tags": ["prefix:moonshot-chat", "provider:moonshot", "protocol:moonshot-chat", "text", "usage", "thinking-off"],
"name": "moonshot-chat/kimi-k2-6-streams-text-with-disabled-thinking",
"recordedAt": "2026-09-07T20:35:09.335Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.moonshot.ai/v1/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"kimi-k2.6\",\"messages\":[{\"role\":\"user\",\"content\":\"What is 173 multiplied by 219? Reply with only the final integer.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"max_tokens\":4096,\"thinking\":{\"type\":\"disabled\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"\"},\"finish_reason\":null}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"378\"},\"finish_reason\":null}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"87\"},\"finish_reason\":null}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\",\"usage\":{\"prompt_tokens\":23,\"completion_tokens\":5,\"total_tokens\":28,\"completion_tokens_details\":{\"reasoning_tokens\":1}}}],\"system_fingerprint\":\"fpv0_45f9e909\"}\n\ndata: {\"id\":\"chatcmpl-6a9f1ffd7d28eaca53fe9b4c\",\"object\":\"chat.completion.chunk\",\"created\":1788813309,\"model\":\"kimi-k2.6\",\"choices\":[],\"usage\":{\"prompt_tokens\":23,\"completion_tokens\":5,\"total_tokens\":28,\"completion_tokens_details\":{\"reasoning_tokens\":1}}}\n\ndata: [DONE]\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"version": 1,
"metadata": {
"model": "kimi-k3",
"tags": ["prefix:moonshot-messages", "provider:moonshot", "protocol:messages", "tool", "tool-choice", "usage"],
"name": "moonshot-messages/k3-respects-tool-choice-required",
"recordedAt": "2026-09-07T20:37:07.770Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.moonshot.ai/anthropic/v1/messages",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"kimi-k3\",\"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\":\"any\"},\"stream\":true,\"max_tokens\":4096,\"output_config\":{\"effort\":\"low\"}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"chatcmpl-2f2e02780e8a031b1b1307171\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"kimi-k3\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":223,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":0,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\",\"prompt_tokens\":223,\"cached_tokens\":0}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"thinking\",\"thinking\":\"\",\"signature\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"Need\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" get\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"_we\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\"ather\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" for\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" Paris\"}}\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\":\"thinking_delta\",\"thinking\":\" Call\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" function\"}}\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\":\"thinking_delta\",\"thinking\":\" Then\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" final\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" summarize\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" result\"}}\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\":\"thinking_delta\",\"thinking\":\" Use\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\" tool\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"thinking_delta\",\"thinking\":\".\"}}\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\":\"get_weather_0\",\"name\":\"get_weather\",\"input\":{}}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"city\\\": \\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Paris\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"}\"}}\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\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":0,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":223,\"output_tokens\":69,\"output_tokens_details\":{\"thinking_tokens\":19},\"prompt_tokens\":223,\"completion_tokens\":69,\"total_tokens\":292,\"cached_tokens\":223}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
{
"version": 1,
"metadata": {
"model": "kimi-k3",
"tags": ["prefix:moonshot-responses", "provider:moonshot", "protocol:responses", "tool", "tool-choice", "error"],
"name": "moonshot-responses/k3-rejects-unsupported-tool-choice-none",
"recordedAt": "2026-09-07T20:38:12.966Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.moonshot.ai/v1/responses",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"kimi-k3\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"tool_choice\":\"none\",\"reasoning\":{\"effort\":\"low\"},\"max_output_tokens\":4096,\"stream\":true}"
},
"response": {
"status": 400,
"headers": {
"content-type": "application/json"
},
"body": "{\"error\":{\"message\":\"invalid_request_error: responses: unsupported tool_choice value: \\\"none\\\"\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}\n"
}
}
]
}
@@ -0,0 +1,29 @@
{
"version": 1,
"metadata": {
"model": "kimi-k3",
"tags": ["prefix:moonshot-responses", "provider:moonshot", "protocol:responses", "tool", "tool-choice", "error"],
"name": "moonshot-responses/k3-rejects-unsupported-tool-choice-required",
"recordedAt": "2026-09-07T20:38:12.717Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.moonshot.ai/v1/responses",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"kimi-k3\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use get_weather to look up the current weather in Paris.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather in a city\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\",\"enum\":[\"Paris\"]}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"tool_choice\":\"required\",\"reasoning\":{\"effort\":\"low\"},\"max_output_tokens\":4096,\"stream\":true}"
},
"response": {
"status": 400,
"headers": {
"content-type": "application/json"
},
"body": "{\"error\":{\"message\":\"invalid_request_error: responses: unsupported tool_choice value: \\\"required\\\"\",\"type\":\"invalid_request_error\",\"param\":null,\"code\":\"invalid_request_error\"}}\n"
}
}
]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
import { LLM } from "../../src/index.js"
import { Moonshot } from "../../src/providers.js"
const moonshot = Moonshot.configure()
LLM.request({ model: moonshot.model("kimi-k2.6"), providerOptions: { thinking: { type: "enabled", keep: "all" } } })
LLM.request({ model: moonshot.chat("kimi-k2.6"), providerOptions: { thinking: { type: "disabled", keep: null } } })
LLM.request({ model: moonshot.chat("kimi-k3"), providerOptions: { reasoningEffort: "high" } })
LLM.request({
model: moonshot.messages("kimi-k3"),
providerOptions: { effort: "low", metadata: { user_id: "fixture" } },
})
LLM.request({ model: moonshot.responses("kimi-k3"), providerOptions: { reasoningEffort: "future-effort" } })
LLM.request({
model: moonshot.messages("kimi-k3"),
// @ts-expect-error Messages exposes effort rather than the K2 Chat thinking parameter.
providerOptions: { thinking: { type: "enabled" } },
})
LLM.request({
model: moonshot.chat("kimi-k2.6"),
// @ts-expect-error Preserved thinking takes a string or null.
providerOptions: { thinking: { type: "enabled", keep: true } },
})
LLM.request({
model: moonshot.responses("kimi-k3"),
// @ts-expect-error Responses uses reasoningEffort rather than the Messages effort option.
providerOptions: { effort: "high" },
})
+29
View File
@@ -41,6 +41,10 @@ describe("provider package entrypoints", () => {
import("@opencode/ai/providers/minimax/messages"),
import("@opencode/ai/providers/minimax/chat"),
import("@opencode/ai/providers/minimax/responses"),
import("@opencode/ai/providers/moonshot"),
import("@opencode/ai/providers/moonshot/chat"),
import("@opencode/ai/providers/moonshot/messages"),
import("@opencode/ai/providers/moonshot/responses"),
])
for (const module of modules) expect(module.model).toBeFunction()
@@ -51,6 +55,31 @@ describe("provider package entrypoints", () => {
expect(modules[19].model).not.toBe(modules[20].model)
})
test("maps Moonshot API entrypoints onto provider-owned routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/moonshot"),
import("@opencode/ai/providers/moonshot/chat"),
import("@opencode/ai/providers/moonshot/messages"),
import("@opencode/ai/providers/moonshot/responses"),
])
expect(modules[0].model).toBe(modules[1].model)
const settings = {
apiKey: "fixture",
baseURL: "https://gateway.example/v1",
headers: { "x-application": "fixture" },
body: { future_option: true },
}
const routes = ["moonshot-chat", "moonshot-chat", "moonshot-messages", "moonshot-responses"]
modules.forEach((module, index) => {
const selected = module.model("kimi-k3", settings)
expect(selected.provider).toBe("moonshotai")
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 MiniMax API entrypoints onto provider-owned routes", async () => {
const modules = await Promise.all([
import("@opencode/ai/providers/minimax"),
@@ -0,0 +1,131 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, Message, ToolDefinition } from "../../src/index.js"
import { Moonshot } from "../../src/providers.js"
import { LLMClient } from "../../src/route.js"
import { recordedTests } from "../recorded-test.js"
const apiKey = process.env.MOONSHOT_API_KEY ?? process.env.MOONSHOTAI_API_KEY ?? "fixture"
const requires = [process.env.MOONSHOT_API_KEY ? "MOONSHOT_API_KEY" : "MOONSHOTAI_API_KEY"]
const schema = {
type: "object",
properties: { city: { type: "string", enum: ["Paris"] } },
required: ["city"],
additionalProperties: false,
}
const weather = ToolDefinition.make({
name: "get_weather",
description: "Get the current weather in a city",
inputSchema: schema,
})
for (const api of ["chat", "messages", "responses"] as const) {
const model = Moonshot.configure({
apiKey,
providerOptions: api === "messages" ? { effort: "low" } : { reasoningEffort: "low" },
})[api]("kimi-k3")
const recorded = recordedTests({
prefix: `moonshot-${api}`,
provider: "moonshot",
protocol: api,
requires,
metadata: { model: "kimi-k3" },
})
for (const toolChoice of ["required", "none"] as const) {
recorded.effect.with(
`K3 ${api === "responses" ? "rejects unsupported" : "respects"} tool choice ${toolChoice}`,
{ tags: ["tool", "tool-choice", api === "responses" ? "error" : "usage"] },
() =>
Effect.gen(function* () {
const generate = LLMClient.generate(
LLM.request({
model,
prompt: "Use get_weather to look up the current weather in Paris.",
tools: [weather],
toolChoice,
generation: { maxTokens: 4096 },
}),
)
if (api === "responses") {
const error = yield* generate.pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason.http?.status).toBe(400)
expect(error.message).toContain(`unsupported tool_choice value: "${toolChoice}"`)
return
}
const response = yield* generate
if (toolChoice === "required") {
expect(response.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(response.finishReason.normalized).toBe("tool-calls")
}
if (toolChoice === "none") {
expect(response.toolCalls).toHaveLength(0)
expect(response.text.length).toBeGreaterThan(0)
expect(response.finishReason.normalized).toBe("stop")
}
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
120_000,
)
}
recorded.effect.with(
"K3 reads image bytes",
{ tags: ["image", "reasoning", "usage"] },
() =>
Effect.gen(function* () {
const image = yield* Effect.promise(() =>
Bun.file(new URL("../fixtures/media/restroom.png", import.meta.url)).bytes(),
)
const response = yield* LLMClient.generate(
LLM.request({
model,
messages: [
Message.user([
{ type: "text", text: "Read the three words in this image. Reply only with those words in order." },
{ type: "media", mediaType: "image/png", data: image },
]),
],
generation: { maxTokens: 4096 },
}),
)
expect(response.text.toLowerCase()).toContain("jiggling restroom prison")
expect(response.finishReason.normalized).toBe("stop")
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
}),
120_000,
)
recorded.effect.with(
"K3 returns native structured output",
{ tags: ["structured-output", "usage"] },
() =>
Effect.gen(function* () {
const format = { type: "json_schema", name: "location", schema, strict: true }
const response = yield* LLMClient.generate(
LLM.request({
model,
prompt: "Return a JSON object containing the capital city of France.",
generation: { maxTokens: 4096 },
http: {
body:
api === "chat"
? {
response_format: { type: "json_schema", json_schema: { name: "location", schema, strict: true } },
}
: api === "messages"
? { output_config: { format: { type: "json_schema", schema } } }
: { text: { format } },
},
}),
)
expect(JSON.parse(response.text)).toEqual({ city: "Paris" })
expect(response.finishReason.normalized).toBe("stop")
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
}),
120_000,
)
}
@@ -0,0 +1,191 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolDefinition, type LLMResponse } from "../../src/index.js"
import { Moonshot } 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.MOONSHOT_API_KEY ?? process.env.MOONSHOTAI_API_KEY ?? "fixture"
const requires = [process.env.MOONSHOT_API_KEY ? "MOONSHOT_API_KEY" : "MOONSHOTAI_API_KEY"]
for (const api of ["chat", "messages", "responses"] as const) {
const recorded = recordedTests({ prefix: `moonshot-${api}`, provider: "moonshot", protocol: api, requires })
for (const effort of [undefined, "low", "high", "max"] as const) {
recorded.effect.with(
`K3 streams text with ${effort ?? "default"} effort`,
{ tags: ["text", "reasoning", "usage"], metadata: { model: "kimi-k3" } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Moonshot.configure({
apiKey,
providerOptions: api === "messages" ? { effort } : { reasoningEffort: effort },
})[api]("kimi-k3"),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
})
const compiled = yield* compileRequest(request)
expect(compiled.body.thinking).toBeUndefined()
if (effort !== undefined) {
expect(compiled.body).toMatchObject(
api === "chat"
? { reasoning_effort: effort }
: api === "messages"
? { output_config: { effort } }
: { reasoning: { effort } },
)
}
const response = yield* LLMClient.generate(request)
expect(response.text.replaceAll(",", "")).toContain("37887")
expect(response.reasoning.length).toBeGreaterThan(0)
expect(response.finishReason.normalized).toBe("stop")
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
expectUsage(response)
}),
120_000,
)
}
}
const chat = recordedTests({ prefix: "moonshot-chat", provider: "moonshot", protocol: "moonshot-chat", requires })
for (const item of [
{ model: "kimi-k2.6", name: "default thinking", thinking: undefined },
{ model: "kimi-k2.6", name: "disabled thinking", thinking: { type: "disabled" } },
{ model: "kimi-k2.6", name: "enabled thinking", thinking: { type: "enabled", keep: null } },
{ model: "kimi-k2.7-code", name: "default thinking", thinking: undefined },
{ model: "kimi-k2.7-code-highspeed", name: "default thinking", thinking: undefined },
]) {
chat.effect.with(
`${item.model} streams text with ${item.name}`,
{
tags: ["text", "usage", item.thinking?.type === "disabled" ? "thinking-off" : "reasoning"],
metadata: { model: item.model },
},
() =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: Moonshot.configure({ apiKey, providerOptions: { thinking: item.thinking } }).chat(item.model),
prompt: "What is 173 multiplied by 219? Reply with only the final integer.",
generation: { maxTokens: 4096 },
}),
)
expect(response.text.replaceAll(",", "")).toContain("37887")
expect(response.reasoning.length > 0).toBe(item.thinking?.type !== "disabled")
expect(response.finishReason.normalized).toBe("stop")
expectUsage(response)
}),
120_000,
)
}
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,
},
})
for (const item of [
{ api: "chat", model: "kimi-k3", options: { reasoningEffort: "low" } },
{ api: "messages", model: "kimi-k3", options: { effort: "low" } },
{ api: "responses", model: "kimi-k3", options: { reasoningEffort: "low" } },
{ api: "chat", model: "kimi-k2.6", options: { thinking: { type: "enabled", keep: "all" } } },
{ api: "chat", model: "kimi-k2.7-code", options: {} },
{ api: "chat", model: "kimi-k2.7-code-highspeed", options: {} },
] as const) {
const recorded = recordedTests({ prefix: `moonshot-${item.api}`, provider: "moonshot", protocol: item.api, requires })
recorded.effect.with(
`${item.model} preserves reasoning through a tool loop and follow-up`,
{ tags: ["tool", "tool-loop", "reasoning", "continuation", "usage"], metadata: { model: item.model } },
() =>
Effect.gen(function* () {
const request = LLM.request({
model: Moonshot.configure({ apiKey, providerOptions: item.options })[item.api](item.model),
prompt:
"Use get_weather to look up the current weather in Paris. After receiving the result, report the weather in one short sentence.",
tools: [weather],
generation: { maxTokens: 4096 },
})
const first = yield* LLMClient.generate(request)
expect(first.finishReason.normalized).toBe("tool-calls")
expect(first.toolCalls).toMatchObject([{ name: "get_weather", input: { city: "Paris" } }])
expect(first.reasoning.length).toBeGreaterThan(0)
expectUsage(first)
const continuation = LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
...first.toolCalls.map((call) =>
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
),
],
})
const compiled = yield* compileRequest(continuation)
if (item.api === "chat")
expect(compiled.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "assistant", reasoning_content: first.reasoning }),
]),
)
if (item.api === "messages")
expect(compiled.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: expect.arrayContaining(
first.message.content
.filter((part) => part.type === "reasoning")
.map((part) => ({
type: "thinking",
thinking: part.text,
signature: part.providerMetadata?.moonshot?.signature ?? "",
})),
),
}),
]),
)
if (item.api === "responses")
expect(compiled.body.input).toEqual(
expect.arrayContaining([
expect.objectContaining({
type: "reasoning",
summary: expect.arrayContaining([{ type: "summary_text", text: first.reasoning }]),
}),
]),
)
const second = yield* LLMClient.generate(continuation)
expect(second.text).toContain("Paris")
expect(second.text.toLowerCase()).toContain("sunny")
expect(second.toolCalls).toHaveLength(0)
expect(second.finishReason.normalized).toBe("stop")
expectUsage(second)
const third = yield* LLMClient.generate(
LLMRequest.update(continuation, {
messages: [
...continuation.messages,
second.message,
Message.user("What temperature did the tool report? Reply with only the temperature."),
],
}),
)
expect(third.text).toContain("18")
expect(third.toolCalls).toHaveLength(0)
expect(third.finishReason.normalized).toBe("stop")
expectUsage(third)
}),
180_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)
}
+214
View File
@@ -0,0 +1,214 @@
import { expect } from "bun:test"
import { ConfigProvider, Effect } from "effect"
import { Headers } from "effect/unstable/http"
import { Auth, LLM, LLMClient, Message, ReasoningPart, ToolDefinition } from "../../src/index.js"
import { Moonshot } from "../../src/providers.js"
import { AnthropicMessages } from "../../src/protocols/anthropic-messages.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { compileRequest } from "../../src/route/client.js"
import { Endpoint } from "../../src/route/endpoint.js"
import { it } from "../lib/effect.js"
import { dynamicResponse } from "../lib/http.js"
import { sseEvents } from "../lib/sse.js"
it.effect("Moonshot composes shared protocols with explicit endpoints and bearer authentication", () =>
Effect.gen(function* () {
const moonshot = Moonshot.configure()
expect(moonshot.model).toBe(moonshot.chat)
expect(moonshot.messages("kimi-k3").route.body).toBe(AnthropicMessages.protocol.body)
expect(moonshot.responses("kimi-k3").route.body).toBe(OpenResponses.protocol.body)
for (const item of [
{ model: moonshot.chat("kimi-k3"), path: "/v1/chat/completions" },
{ model: moonshot.messages("kimi-k3"), path: "/anthropic/v1/messages" },
{ model: moonshot.responses("kimi-k3"), path: "/v1/responses" },
]) {
const request = LLM.request({ model: item.model, prompt: "Hello" })
const compiled = yield* compileRequest(request)
const url = Endpoint.render(item.model.route.endpoint, { request, body: compiled.body }).toString()
expect(url).toBe(`https://api.moonshot.ai${item.path}`)
expect(item.model.provider).toBe("moonshotai")
expect(item.model.route.providerMetadataKey).toBe("moonshot")
const headers = yield* item.model.route.auth.apply({
request,
method: "POST",
url,
body: "{}",
headers: Headers.empty,
})
expect(headers.authorization).toBe("Bearer primary")
}
}).pipe(
Effect.provide(
ConfigProvider.layer(
ConfigProvider.fromEnv({ env: { MOONSHOT_API_KEY: "primary", MOONSHOTAI_API_KEY: "fallback" } }),
),
),
),
)
it.effect("Moonshot preserves default reasoning across model IDs on custom endpoints", () =>
Effect.gen(function* () {
const moonshot = Moonshot.configure({
baseURL: "https://gateway.example/v1",
auth: Auth.header("x-api-key", "fixture"),
})
for (const id of ["kimi-k2.6", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k3", "future-model"]) {
const model = moonshot.chat(id)
const request = LLM.request({ model, prompt: "Hello", generation: { maxTokens: 100 } })
const compiled = yield* compileRequest(request)
expect(compiled.body).toMatchObject({ model: id, max_tokens: 100, stream: true })
expect(compiled.body.max_completion_tokens).toBeUndefined()
expect(compiled.body.store).toBeUndefined()
expect(compiled.body.thinking).toBeUndefined()
expect(compiled.body.reasoning_effort).toBeUndefined()
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
const headers = yield* model.route.auth.apply({
request,
method: "POST",
url: "https://gateway.example/v1/chat/completions",
body: "{}",
headers: Headers.empty,
})
expect(headers["x-api-key"]).toBe("fixture")
expect(headers.authorization).toBeUndefined()
}
}),
)
it.effect("Moonshot honors fallback credentials and final HTTP body overlays", () =>
LLMClient.generate(
LLM.request({
model: Moonshot.configure({ providerOptions: { reasoningEffort: "high" } }).responses("kimi-k3"),
prompt: "Hello",
providerOptions: { reasoningEffort: "low" },
http: { body: { reasoning: { effort: "max" }, future_option: true } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.headers.authorization).toBe("Bearer fallback")
expect(JSON.parse(input.text)).toMatchObject({ reasoning: { effort: "max" }, future_option: true })
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { MOONSHOTAI_API_KEY: "fallback" } }))),
),
)
it.effect("Moonshot lowers API-specific reasoning options without adding unrelated controls", () =>
Effect.gen(function* () {
const moonshot = Moonshot.configure({ apiKey: "fixture" })
for (const thinking of [{ type: "disabled" }, { type: "enabled", keep: "all" }, { type: "enabled", keep: null }]) {
const result = yield* compileRequest(
LLM.request({ model: moonshot.chat("kimi-k2.6"), providerOptions: { thinking } }),
)
expect(result.body.thinking).toEqual(thinking)
expect(result.body.reasoning_effort).toBeUndefined()
}
for (const effort of ["low", "high", "max", "future-effort"]) {
const chat = yield* compileRequest(
LLM.request({ model: moonshot.chat("kimi-k3"), providerOptions: { reasoningEffort: effort } }),
)
const messages = yield* compileRequest(
LLM.request({ model: moonshot.messages("kimi-k3"), providerOptions: { effort } }),
)
const responses = yield* compileRequest(
LLM.request({ model: moonshot.responses("kimi-k3"), providerOptions: { reasoningEffort: effort } }),
)
expect(chat.body.reasoning_effort).toBe(effort)
expect(messages.body.output_config).toEqual({ effort })
expect(responses.body.reasoning).toEqual({ effort })
for (const compiled of [chat, messages, responses]) expect(compiled.body.thinking).toBeUndefined()
expect(responses.body.store).toBeUndefined()
expect(responses.body.include).toBeUndefined()
}
}),
)
it.effect("Moonshot validates native Chat thinking options", () =>
Effect.gen(function* () {
const invalid = Moonshot.configure({ apiKey: "fixture" })
.chat("kimi-k2.6")
.route.with({ providerOptions: { thinking: { type: "enabled", keep: 42 } } })
.model({ id: "kimi-k2.6" })
const failure = yield* compileRequest(LLM.request({ model: invalid })).pipe(Effect.flip)
expect(failure.reason._tag).toBe("InvalidRequest")
expect(failure.message).toContain("keep")
}),
)
it.effect("Moonshot retains unsigned and signed Messages reasoning and projects tool schemas", () =>
Effect.gen(function* () {
const compiled = yield* compileRequest(
LLM.request({
model: Moonshot.configure({ apiKey: "fixture" }).messages("kimi-k3"),
messages: [
Message.user("Hello"),
Message.assistant([
ReasoningPart.make({ type: "reasoning", text: "Unsigned" }),
ReasoningPart.make({
type: "reasoning",
text: "Signed",
providerMetadata: { moonshot: { signature: "opaque-signature" } },
}),
]),
],
tools: [
ToolDefinition.make({
name: "lookup",
description: "Look up a pair",
inputSchema: {
type: "object",
properties: { pair: { type: "array", prefixItems: [{ type: "string" }, { type: "number" }] } },
},
}),
],
}),
)
expect(compiled.body.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
role: "assistant",
content: [
{ type: "thinking", thinking: "Unsigned", signature: "" },
{ type: "thinking", thinking: "Signed", signature: "opaque-signature" },
],
}),
]),
)
expect(compiled.body.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({
input_schema: {
type: "object",
properties: { pair: { type: "array", items: { anyOf: [{ type: "string" }, { type: "number" }] } } },
},
}),
]),
)
}),
)
it.effect("Moonshot Responses uses HTTP even with a WebSocket executor", () =>
LLMClient.generate(
LLM.request({ model: Moonshot.configure({ apiKey: "fixture" }).responses("kimi-k3"), prompt: "Hello" }),
{
webSocket: { execute: () => Effect.die("Unexpected WebSocket request") },
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.sync(() => {
expect(input.request.url).toBe("https://api.moonshot.ai/v1/responses")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)