mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(ai): add Moonshot provider (#47851)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
packages/ai/test/fixtures/recordings/moonshot-chat/kimi-k2-6-streams-text-with-default-thinking.json
Vendored
+29
File diff suppressed because one or more lines are too long
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
packages/ai/test/fixtures/recordings/moonshot-chat/kimi-k2-6-streams-text-with-enabled-thinking.json
Vendored
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
Vendored
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
+74
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
packages/ai/test/fixtures/recordings/moonshot-responses/k3-rejects-unsupported-tool-choice-none.json
Vendored
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
Vendored
+29
File diff suppressed because one or more lines are too long
+74
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" },
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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" },
|
||||
})
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user