mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
feat(providers): add LongCat API support (#100501)
* fix(ai): honor provider reasoning compatibility * feat(longcat): add hosted provider plugin * chore(longcat): register package metadata * docs(longcat): add provider setup guide * docs(longcat): document self-hosted model routing * refactor(longcat): externalize provider plugin * chore(longcat): add npm release artifacts
This commit is contained in:
parent
8336a94ecb
commit
bf8626c0e9
31 changed files with 792 additions and 6 deletions
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
|
|
@ -448,6 +448,11 @@
|
|||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/kimi-coding/**"
|
||||
"extensions: longcat":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/longcat/**"
|
||||
- "docs/providers/longcat.md"
|
||||
"extensions: minimax":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
|
|
|||
|
|
@ -1481,6 +1481,7 @@
|
|||
"providers/kilocode",
|
||||
"providers/litellm",
|
||||
"providers/lmstudio",
|
||||
"providers/longcat",
|
||||
"providers/minimax",
|
||||
"providers/mistral",
|
||||
"providers/moonshot",
|
||||
|
|
|
|||
|
|
@ -6261,6 +6261,24 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
|||
- H2: Distribution
|
||||
- H2: Surface
|
||||
|
||||
## plugins/reference/logbook.md
|
||||
|
||||
- Route: /plugins/reference/logbook
|
||||
- Headings:
|
||||
- H1: Logbook plugin
|
||||
- H2: Distribution
|
||||
- H2: Surface
|
||||
- H2: Related docs
|
||||
|
||||
## plugins/reference/longcat.md
|
||||
|
||||
- Route: /plugins/reference/longcat
|
||||
- Headings:
|
||||
- H1: LongCat plugin
|
||||
- H2: Distribution
|
||||
- H2: Surface
|
||||
- H2: Related docs
|
||||
|
||||
## plugins/reference/matrix.md
|
||||
|
||||
- Route: /plugins/reference/matrix
|
||||
|
|
@ -7564,6 +7582,19 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
|||
- H3: Authentication errors (HTTP 401)
|
||||
- H2: Related
|
||||
|
||||
## providers/longcat.md
|
||||
|
||||
- Route: /providers/longcat
|
||||
- Headings:
|
||||
- H2: Install plugin
|
||||
- H2: Getting started
|
||||
- H3: Non-interactive setup
|
||||
- H2: Reasoning behavior
|
||||
- H2: Pricing
|
||||
- H2: Self-hosted LongCat-2.0
|
||||
- H2: Troubleshooting
|
||||
- H2: Related
|
||||
|
||||
## providers/minimax.md
|
||||
|
||||
- Route: /providers/minimax
|
||||
|
|
|
|||
|
|
@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
|
|||
|
||||
## Core npm package
|
||||
|
||||
60 plugins
|
||||
61 plugins
|
||||
|
||||
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
|
||||
|
||||
|
|
@ -105,6 +105,8 @@ Each entry lists the package, distribution route, and description.
|
|||
|
||||
- **[lmstudio](/plugins/reference/lmstudio)** (`@openclaw/lmstudio-provider`) - included in OpenClaw. Adds LM Studio model provider support to OpenClaw.
|
||||
|
||||
- **[logbook](/plugins/reference/logbook)** (`@openclaw/logbook`) - included in OpenClaw. Automatic work journal: captures periodic screen snapshots from a paired node and turns them into a reviewable timeline of your day.
|
||||
|
||||
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds agent-callable tools.
|
||||
|
||||
- **[memory-wiki](/plugins/reference/memory-wiki)** (`@openclaw/memory-wiki`) - included in OpenClaw. Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.
|
||||
|
|
@ -175,7 +177,7 @@ Each entry lists the package, distribution route, and description.
|
|||
|
||||
## Official external packages
|
||||
|
||||
68 plugins
|
||||
69 plugins
|
||||
|
||||
- **[acpx](/plugins/reference/acpx)** (`@openclaw/acpx`) - npm; ClawHub. OpenClaw ACP runtime backend with plugin-owned session and transport management.
|
||||
|
||||
|
|
@ -247,6 +249,8 @@ Each entry lists the package, distribution route, and description.
|
|||
|
||||
- **[lobster](/plugins/reference/lobster)** (`@openclaw/lobster`) - npm; ClawHub. Lobster workflow tool plugin for typed pipelines and resumable approvals.
|
||||
|
||||
- **[longcat](/plugins/reference/longcat)** (`@openclaw/longcat-provider`) - npm; ClawHub: `clawhub:@openclaw/longcat-provider`. OpenClaw LongCat provider plugin.
|
||||
|
||||
- **[matrix](/plugins/reference/matrix)** (`@openclaw/matrix`) - ClawHub: `clawhub:@openclaw/matrix`; npm. OpenClaw Matrix channel plugin for rooms and direct messages.
|
||||
|
||||
- **[mattermost](/plugins/reference/mattermost)** (`@openclaw/mattermost`) - npm; ClawHub: `clawhub:@openclaw/mattermost`. Adds the Mattermost channel surface for sending and receiving OpenClaw messages.
|
||||
|
|
|
|||
|
|
@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
|
|||
pnpm plugins:inventory:gen
|
||||
```
|
||||
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 130
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 132
|
||||
generated plugin reference pages by distribution, package, and description.
|
||||
|
|
|
|||
23
docs/plugins/reference/logbook.md
Normal file
23
docs/plugins/reference/logbook.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
summary: "Automatic work journal: captures periodic screen snapshots from a paired node and turns them into a reviewable timeline of your day."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the logbook plugin
|
||||
title: "Logbook plugin"
|
||||
---
|
||||
|
||||
# Logbook plugin
|
||||
|
||||
Automatic work journal: captures periodic screen snapshots from a paired node and turns them into a reviewable timeline of your day.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/logbook`
|
||||
- Install route: included in OpenClaw
|
||||
|
||||
## Surface
|
||||
|
||||
plugin
|
||||
|
||||
## Related docs
|
||||
|
||||
- [logbook](/plugins/logbook)
|
||||
23
docs/plugins/reference/longcat.md
Normal file
23
docs/plugins/reference/longcat.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
---
|
||||
summary: "OpenClaw LongCat provider plugin."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the longcat plugin
|
||||
title: "LongCat plugin"
|
||||
---
|
||||
|
||||
# LongCat plugin
|
||||
|
||||
OpenClaw LongCat provider plugin.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/longcat-provider`
|
||||
- Install route: npm; ClawHub: `clawhub:@openclaw/longcat-provider`
|
||||
|
||||
## Surface
|
||||
|
||||
providers: longcat
|
||||
|
||||
## Related docs
|
||||
|
||||
- [longcat](/providers/longcat)
|
||||
|
|
@ -52,6 +52,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
|
|||
- [Kilocode](/providers/kilocode)
|
||||
- [LiteLLM (unified gateway)](/providers/litellm)
|
||||
- [LM Studio (local models)](/providers/lmstudio)
|
||||
- [LongCat](/providers/longcat)
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Mistral](/providers/mistral)
|
||||
- [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot)
|
||||
|
|
|
|||
129
docs/providers/longcat.md
Normal file
129
docs/providers/longcat.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
---
|
||||
summary: "LongCat API setup for LongCat-2.0"
|
||||
title: "LongCat"
|
||||
read_when:
|
||||
- You want to use LongCat-2.0 with OpenClaw
|
||||
- You need the LongCat API key or model limits
|
||||
---
|
||||
|
||||
[LongCat](https://longcat.ai) provides a hosted API for LongCat-2.0, a
|
||||
reasoning model built for coding and agentic workloads. OpenClaw provides the
|
||||
official `longcat` plugin for LongCat's OpenAI-compatible endpoint.
|
||||
|
||||
| Property | Value |
|
||||
| ---------- | ---------------------------------- |
|
||||
| Provider | `longcat` |
|
||||
| Auth | `LONGCAT_API_KEY` |
|
||||
| API | OpenAI-compatible Chat Completions |
|
||||
| Base URL | `https://api.longcat.chat/openai` |
|
||||
| Model | `longcat/LongCat-2.0` |
|
||||
| Context | 1,048,576 tokens |
|
||||
| Max output | 131,072 tokens |
|
||||
| Input | Text |
|
||||
|
||||
## Install plugin
|
||||
|
||||
Install the official package, then restart Gateway:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/longcat-provider
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
## Getting started
|
||||
|
||||
<Steps>
|
||||
<Step title="Create an API key">
|
||||
Sign in to the [LongCat API Platform](https://longcat.chat/platform/) and
|
||||
create a key on the [API Keys](https://longcat.chat/platform/api_keys)
|
||||
page.
|
||||
</Step>
|
||||
<Step title="Run onboarding">
|
||||
```bash
|
||||
openclaw onboard --auth-choice longcat-api-key
|
||||
```
|
||||
</Step>
|
||||
<Step title="Verify the model">
|
||||
```bash
|
||||
openclaw models list --provider longcat
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
Onboarding adds the hosted catalog and selects `longcat/LongCat-2.0` when no
|
||||
primary model is already configured.
|
||||
|
||||
### Non-interactive setup
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice longcat-api-key \
|
||||
--longcat-api-key "$LONGCAT_API_KEY"
|
||||
```
|
||||
|
||||
## Reasoning behavior
|
||||
|
||||
LongCat exposes binary thinking control. OpenClaw maps enabled thinking levels
|
||||
to `thinking: { type: "enabled" }` and `/think off` to
|
||||
`thinking: { type: "disabled" }`. LongCat does not currently document
|
||||
`reasoning_effort`, so OpenClaw does not send it.
|
||||
|
||||
LongCat returns reasoning in `reasoning_content`. OpenClaw preserves that field
|
||||
when replaying assistant tool-call turns so multi-turn agent sessions retain
|
||||
the provider's expected message shape.
|
||||
|
||||
## Pricing
|
||||
|
||||
The built-in catalog uses LongCat's pay-as-you-go list prices in USD per million
|
||||
tokens: $0.75 uncached input, $0.015 cached input, and $2.95 output. LongCat may
|
||||
offer temporary discounts; the [pricing page](https://longcat.chat/platform/docs/Pricing/LongCat-2.0.html)
|
||||
and your billing records are authoritative.
|
||||
|
||||
## Self-hosted LongCat-2.0
|
||||
|
||||
The `longcat` provider targets LongCat's hosted API. For the open weights on
|
||||
[Hugging Face](https://huggingface.co/meituan-longcat/LongCat-2.0), serve the
|
||||
model through an OpenAI-compatible runtime and use OpenClaw's existing
|
||||
[vLLM](/providers/vllm) or [SGLang](/providers/sglang) provider instead.
|
||||
|
||||
Keep the runtime's exact model identifier in the self-hosted provider catalog;
|
||||
do not route a local deployment through `longcat/LongCat-2.0`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="The key works in a shell but not in the Gateway">
|
||||
Daemon-managed Gateway processes do not inherit every interactive shell
|
||||
variable. Put `LONGCAT_API_KEY` in `~/.openclaw/.env`, configure it through
|
||||
onboarding, or use an approved secret reference.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Requests fail with 402 or 429">
|
||||
`402` means the account has insufficient token quota. `429` means the API
|
||||
key hit a rate limit. Check [LongCat usage](https://longcat.chat/platform/usage)
|
||||
and retry rate-limited requests after the provider's backoff window.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="The model does not appear">
|
||||
Run `openclaw plugins list` and confirm the `longcat` plugin is
|
||||
enabled, then run `openclaw models list --provider longcat`.
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Model providers" href="/concepts/model-providers" icon="layers">
|
||||
Provider configuration, model refs, and failover behavior.
|
||||
</Card>
|
||||
<Card title="LongCat API docs" href="https://longcat.chat/platform/docs/" icon="arrow-up-right-from-square">
|
||||
Hosted API endpoints, authentication, limits, and examples.
|
||||
</Card>
|
||||
<Card title="LongCat-2.0 model card" href="https://huggingface.co/meituan-longcat/LongCat-2.0" icon="arrow-up-right-from-square">
|
||||
Architecture, deployment guidance, and model details.
|
||||
</Card>
|
||||
<Card title="Secrets" href="/gateway/secrets" icon="key">
|
||||
Store provider credentials without embedding plaintext in config.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
13
extensions/longcat/README.md
Normal file
13
extensions/longcat/README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# OpenClaw LongCat Provider
|
||||
|
||||
Official OpenClaw provider plugin for LongCat-2.0 through LongCat's hosted
|
||||
OpenAI-compatible API.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/longcat-provider
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
Configure `LONGCAT_API_KEY`, then select `longcat/LongCat-2.0`.
|
||||
10
extensions/longcat/api.ts
Normal file
10
extensions/longcat/api.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// LongCat API module exposes the plugin public contract.
|
||||
export {
|
||||
LONGCAT_BASE_URL,
|
||||
LONGCAT_DEFAULT_MODEL_ID,
|
||||
LONGCAT_DEFAULT_MODEL_REF,
|
||||
LONGCAT_MODEL_CATALOG,
|
||||
} from "./models.js";
|
||||
export { applyLongCatConfig } from "./onboard.js";
|
||||
export { buildLongCatProvider } from "./provider-catalog.js";
|
||||
export { createLongCatThinkingWrapper } from "./stream.js";
|
||||
183
extensions/longcat/index.test.ts
Normal file
183
extensions/longcat/index.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// LongCat tests cover provider registration, onboarding, and wire compatibility.
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import {
|
||||
createAssistantMessageEventStream,
|
||||
type Context,
|
||||
type Model,
|
||||
} from "openclaw/plugin-sdk/llm";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { LONGCAT_DEFAULT_MODEL_REF } from "./models.js";
|
||||
import { applyLongCatConfig } from "./onboard.js";
|
||||
import { buildLongCatProvider } from "./provider-catalog.js";
|
||||
import { createLongCatThinkingWrapper } from "./stream.js";
|
||||
|
||||
function readManifest() {
|
||||
return JSON.parse(readFileSync(new URL("./openclaw.plugin.json", import.meta.url), "utf8")) as {
|
||||
providerAuthChoices?: Array<{ choiceId?: string; optionKey?: string; cliFlag?: string }>;
|
||||
setup?: { providers?: Array<{ id?: string; envVars?: string[] }> };
|
||||
};
|
||||
}
|
||||
|
||||
function requireLongCatModel(): Model<"openai-completions"> {
|
||||
const model = buildLongCatProvider().models?.[0];
|
||||
if (!model) {
|
||||
throw new Error("LongCat catalog did not provide a model");
|
||||
}
|
||||
return {
|
||||
...model,
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://api.longcat.chat/openai",
|
||||
provider: "longcat",
|
||||
input: ["text"],
|
||||
cost: { ...model.cost },
|
||||
} as Model<"openai-completions">;
|
||||
}
|
||||
|
||||
describe("LongCat provider plugin", () => {
|
||||
it("registers the manifest-owned API key onboarding flow", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(provider).toMatchObject({
|
||||
id: "longcat",
|
||||
aliases: ["meituan-longcat"],
|
||||
envVars: ["LONGCAT_API_KEY"],
|
||||
});
|
||||
expect(provider.auth[0]).toMatchObject({
|
||||
id: "api-key",
|
||||
kind: "api_key",
|
||||
wizard: { choiceId: "longcat-api-key" },
|
||||
});
|
||||
expect(readManifest().providerAuthChoices).toEqual([
|
||||
expect.objectContaining({
|
||||
choiceId: "longcat-api-key",
|
||||
optionKey: "longcatApiKey",
|
||||
cliFlag: "--longcat-api-key",
|
||||
}),
|
||||
]);
|
||||
expect(readManifest().setup?.providers).toEqual([
|
||||
{ id: "longcat", envVars: ["LONGCAT_API_KEY"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("exposes the hosted LongCat-2.0 catalog", () => {
|
||||
expect(buildLongCatProvider()).toMatchObject({
|
||||
baseUrl: "https://api.longcat.chat/openai",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
expect.objectContaining({
|
||||
id: "LongCat-2.0",
|
||||
reasoning: true,
|
||||
contextWindow: 1_048_576,
|
||||
maxTokens: 131_072,
|
||||
compat: {
|
||||
supportsStore: false,
|
||||
supportsDeveloperRole: false,
|
||||
supportsReasoningEffort: false,
|
||||
supportsUsageInStreaming: false,
|
||||
supportsStrictMode: false,
|
||||
maxTokensField: "max_tokens",
|
||||
requiresReasoningContentOnAssistantMessages: true,
|
||||
thinkingFormat: "deepseek",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("applies the LongCat catalog without replacing an existing primary model", () => {
|
||||
const result = applyLongCatConfig({
|
||||
agents: { defaults: { model: { primary: "openai/gpt-5.5" } } },
|
||||
});
|
||||
|
||||
expect(resolveAgentModelPrimaryValue(result.agents?.defaults?.model)).toBe("openai/gpt-5.5");
|
||||
expect(result.agents?.defaults?.models?.[LONGCAT_DEFAULT_MODEL_REF]).toEqual({
|
||||
alias: "LongCat 2.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses LongCat thinking and replay fields without reasoning_effort", () => {
|
||||
const model = requireLongCatModel();
|
||||
const context = {
|
||||
systemPrompt: "system",
|
||||
messages: [
|
||||
{ role: "user", content: "read it", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
api: "openai-completions",
|
||||
provider: "longcat",
|
||||
model: "LongCat-2.0",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "use the read tool",
|
||||
thinkingSignature: "reasoning_content",
|
||||
},
|
||||
{ type: "toolCall", id: "call_1", name: "read", arguments: {} },
|
||||
],
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "toolUse",
|
||||
timestamp: 2,
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "read",
|
||||
description: "Read data",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
} as Context;
|
||||
let payload: Record<string, unknown> | undefined;
|
||||
const baseStreamFn: StreamFn = (streamModel, streamContext, options) => {
|
||||
const params = buildOpenAICompletionsParams(
|
||||
streamModel as Model<"openai-completions">,
|
||||
streamContext,
|
||||
{ maxTokens: 2048, reasoning: "high" } as never,
|
||||
) as Record<string, unknown>;
|
||||
options?.onPayload?.(params, streamModel);
|
||||
const stream = createAssistantMessageEventStream();
|
||||
queueMicrotask(() => stream.end());
|
||||
return stream;
|
||||
};
|
||||
const wrappedStreamFn = createLongCatThinkingWrapper(baseStreamFn, "high");
|
||||
|
||||
void wrappedStreamFn(model, context, {
|
||||
onPayload: (nextPayload) => {
|
||||
payload = nextPayload as Record<string, unknown>;
|
||||
},
|
||||
});
|
||||
if (!payload) {
|
||||
throw new Error("LongCat payload was not captured");
|
||||
}
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
max_tokens: 2048,
|
||||
thinking: { type: "enabled" },
|
||||
});
|
||||
expect(payload).not.toHaveProperty("max_completion_tokens");
|
||||
expect(payload).not.toHaveProperty("reasoning_effort");
|
||||
expect(payload).not.toHaveProperty("store");
|
||||
expect(payload).not.toHaveProperty("stream_options");
|
||||
expect(payload.messages).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ role: "system", content: "system" }),
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
reasoning_content: "use the read tool",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
53
extensions/longcat/index.ts
Normal file
53
extensions/longcat/index.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// LongCat plugin entrypoint registers its OpenClaw integration.
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
|
||||
import { LONGCAT_DEFAULT_MODEL_REF } from "./models.js";
|
||||
import { applyLongCatConfig } from "./onboard.js";
|
||||
import { buildLongCatProvider } from "./provider-catalog.js";
|
||||
import { createLongCatThinkingWrapper } from "./stream.js";
|
||||
|
||||
const PROVIDER_ID = "longcat";
|
||||
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "LongCat Provider",
|
||||
description: "Official LongCat provider plugin",
|
||||
provider: {
|
||||
label: "LongCat",
|
||||
docsPath: "/providers/longcat",
|
||||
aliases: ["meituan-longcat"],
|
||||
envVars: ["LONGCAT_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
methodId: "api-key",
|
||||
label: "LongCat API key",
|
||||
hint: "API key",
|
||||
optionKey: "longcatApiKey",
|
||||
flagName: "--longcat-api-key",
|
||||
envVar: "LONGCAT_API_KEY",
|
||||
promptMessage: "Enter LongCat API key",
|
||||
defaultModel: LONGCAT_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyLongCatConfig(cfg),
|
||||
noteTitle: "LongCat",
|
||||
noteMessage: "Manage API keys at https://longcat.chat/platform/api_keys",
|
||||
wizard: {
|
||||
choiceId: "longcat-api-key",
|
||||
choiceLabel: "LongCat API key",
|
||||
groupId: "longcat",
|
||||
groupLabel: "LongCat",
|
||||
groupHint: "API key",
|
||||
},
|
||||
},
|
||||
],
|
||||
catalog: {
|
||||
buildProvider: buildLongCatProvider,
|
||||
},
|
||||
...buildProviderReplayFamilyHooks({
|
||||
family: "openai-compatible",
|
||||
dropReasoningFromHistory: false,
|
||||
}),
|
||||
...buildProviderToolCompatFamilyHooks("openai"),
|
||||
wrapStreamFn: (ctx) => createLongCatThinkingWrapper(ctx.streamFn, ctx.thinkingLevel),
|
||||
},
|
||||
});
|
||||
14
extensions/longcat/models.ts
Normal file
14
extensions/longcat/models.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// LongCat plugin module implements models behavior.
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
const LONGCAT_MANIFEST_PROVIDER = buildManifestModelProviderConfig({
|
||||
providerId: "longcat",
|
||||
catalog: manifest.modelCatalog.providers.longcat,
|
||||
});
|
||||
|
||||
export const LONGCAT_BASE_URL = LONGCAT_MANIFEST_PROVIDER.baseUrl;
|
||||
export const LONGCAT_MODEL_CATALOG: ModelDefinitionConfig[] = LONGCAT_MANIFEST_PROVIDER.models;
|
||||
export const LONGCAT_DEFAULT_MODEL_ID = "LongCat-2.0";
|
||||
export const LONGCAT_DEFAULT_MODEL_REF = `longcat/${LONGCAT_DEFAULT_MODEL_ID}`;
|
||||
12
extensions/longcat/npm-shrinkwrap.json
generated
Normal file
12
extensions/longcat/npm-shrinkwrap.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "@openclaw/longcat-provider",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/longcat-provider",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
extensions/longcat/onboard.ts
Normal file
21
extensions/longcat/onboard.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// LongCat setup module handles plugin onboarding behavior.
|
||||
import {
|
||||
createModelCatalogPresetAppliers,
|
||||
type OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { LONGCAT_BASE_URL, LONGCAT_DEFAULT_MODEL_REF, LONGCAT_MODEL_CATALOG } from "./models.js";
|
||||
|
||||
const longCatPresetAppliers = createModelCatalogPresetAppliers({
|
||||
primaryModelRef: LONGCAT_DEFAULT_MODEL_REF,
|
||||
resolveParams: (_cfg: OpenClawConfig) => ({
|
||||
providerId: "longcat",
|
||||
api: "openai-completions",
|
||||
baseUrl: LONGCAT_BASE_URL,
|
||||
catalogModels: LONGCAT_MODEL_CATALOG,
|
||||
aliases: [{ modelRef: LONGCAT_DEFAULT_MODEL_REF, alias: "LongCat 2.0" }],
|
||||
}),
|
||||
});
|
||||
|
||||
export function applyLongCatConfig(cfg: OpenClawConfig): OpenClawConfig {
|
||||
return longCatPresetAppliers.applyConfig(cfg);
|
||||
}
|
||||
82
extensions/longcat/openclaw.plugin.json
Normal file
82
extensions/longcat/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
{
|
||||
"id": "longcat",
|
||||
"name": "LongCat",
|
||||
"description": "OpenClaw LongCat provider plugin.",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["longcat"],
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"longcat": {
|
||||
"family": "longcat"
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelCatalog": {
|
||||
"providers": {
|
||||
"longcat": {
|
||||
"baseUrl": "https://api.longcat.chat/openai",
|
||||
"api": "openai-completions",
|
||||
"models": [
|
||||
{
|
||||
"id": "LongCat-2.0",
|
||||
"name": "LongCat 2.0",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"contextWindow": 1048576,
|
||||
"maxTokens": 131072,
|
||||
"cost": {
|
||||
"input": 0.75,
|
||||
"output": 2.95,
|
||||
"cacheRead": 0.015,
|
||||
"cacheWrite": 0.75
|
||||
},
|
||||
"compat": {
|
||||
"supportsStore": false,
|
||||
"supportsDeveloperRole": false,
|
||||
"supportsReasoningEffort": false,
|
||||
"supportsUsageInStreaming": false,
|
||||
"supportsStrictMode": false,
|
||||
"maxTokensField": "max_tokens",
|
||||
"requiresReasoningContentOnAssistantMessages": true,
|
||||
"thinkingFormat": "deepseek"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"longcat": "static"
|
||||
}
|
||||
},
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "longcat",
|
||||
"envVars": ["LONGCAT_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "longcat",
|
||||
"method": "api-key",
|
||||
"choiceId": "longcat-api-key",
|
||||
"choiceLabel": "LongCat API key",
|
||||
"groupId": "longcat",
|
||||
"groupLabel": "LongCat",
|
||||
"groupHint": "API key",
|
||||
"optionKey": "longcatApiKey",
|
||||
"cliFlag": "--longcat-api-key",
|
||||
"cliOption": "--longcat-api-key <key>",
|
||||
"cliDescription": "LongCat API key"
|
||||
}
|
||||
],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
35
extensions/longcat/package.json
Normal file
35
extensions/longcat/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@openclaw/longcat-provider",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw LongCat provider plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/longcat-provider",
|
||||
"npmSpec": "@openclaw/longcat-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
11
extensions/longcat/provider-catalog.ts
Normal file
11
extensions/longcat/provider-catalog.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
// LongCat provider module implements model/runtime integration.
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
export function buildLongCatProvider(): ModelProviderConfig {
|
||||
return buildManifestModelProviderConfig({
|
||||
providerId: "longcat",
|
||||
catalog: manifest.modelCatalog.providers.longcat,
|
||||
});
|
||||
}
|
||||
20
extensions/longcat/stream.ts
Normal file
20
extensions/longcat/stream.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// LongCat stream helpers apply the provider's binary thinking control.
|
||||
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createPayloadPatchStreamWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
|
||||
export function createLongCatThinkingWrapper(
|
||||
baseStreamFn: ProviderWrapStreamFnContext["streamFn"],
|
||||
thinkingLevel: ProviderWrapStreamFnContext["thinkingLevel"],
|
||||
): NonNullable<ProviderWrapStreamFnContext["streamFn"]> {
|
||||
return createPayloadPatchStreamWrapper(
|
||||
baseStreamFn,
|
||||
({ payload }) => {
|
||||
payload.thinking = { type: thinkingLevel === "off" ? "disabled" : "enabled" };
|
||||
delete payload.reasoning_effort;
|
||||
},
|
||||
{
|
||||
shouldPatch: ({ model }) =>
|
||||
model.api === "openai-completions" && model.provider === "longcat",
|
||||
},
|
||||
);
|
||||
}
|
||||
16
extensions/longcat/tsconfig.json
Normal file
16
extensions/longcat/tsconfig.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
|
|
@ -112,6 +112,7 @@
|
|||
"!dist/extensions/line/**",
|
||||
"!dist/extensions/llama-cpp/**",
|
||||
"!dist/extensions/lobster/**",
|
||||
"!dist/extensions/longcat/**",
|
||||
"!dist/extensions/mattermost/**",
|
||||
"!dist/extensions/memory-lancedb/**",
|
||||
"!dist/extensions/matrix/**",
|
||||
|
|
|
|||
|
|
@ -24,8 +24,9 @@ const mockChunksRef: {
|
|||
chunks: OpenAICompatibleChatCompletionChunk[];
|
||||
stream?: AsyncIterable<OpenAICompatibleChatCompletionChunk>;
|
||||
} = { chunks: [] };
|
||||
const mockOpenAIOptionsRef: { options: unknown[]; requests: unknown[] } = {
|
||||
const mockOpenAIOptionsRef: { options: unknown[]; payloads: unknown[]; requests: unknown[] } = {
|
||||
options: [],
|
||||
payloads: [],
|
||||
requests: [],
|
||||
};
|
||||
|
||||
|
|
@ -37,7 +38,8 @@ vi.mock("openai", () => {
|
|||
|
||||
chat = {
|
||||
completions: {
|
||||
create: (_params: unknown, requestOptions: unknown) => {
|
||||
create: (params: unknown, requestOptions: unknown) => {
|
||||
mockOpenAIOptionsRef.payloads.push(params);
|
||||
mockOpenAIOptionsRef.requests.push(requestOptions);
|
||||
return {
|
||||
withResponse: async () => {
|
||||
|
|
@ -70,6 +72,7 @@ import { streamOpenAICompletions, streamSimpleOpenAICompletions } from "./openai
|
|||
beforeEach(() => {
|
||||
mockChunksRef.chunks = [];
|
||||
mockChunksRef.stream = undefined;
|
||||
mockOpenAIOptionsRef.payloads = [];
|
||||
mockOpenAIOptionsRef.requests = [];
|
||||
});
|
||||
|
||||
|
|
@ -161,6 +164,30 @@ function createNeverYieldingStream(): AsyncIterable<OpenAICompatibleChatCompleti
|
|||
}
|
||||
|
||||
describe("OpenAI-compatible completions params", () => {
|
||||
it("omits reasoning_effort when deepseek-format compatibility disables it", async () => {
|
||||
mockChunksRef.chunks = [makeTextChunk("ok"), makeFinishChunk("stop")];
|
||||
const compatibleModel = {
|
||||
...reasoningModel,
|
||||
provider: "longcat",
|
||||
baseUrl: "https://api.longcat.chat/openai",
|
||||
compat: {
|
||||
thinkingFormat: "deepseek" as const,
|
||||
supportsReasoningEffort: false,
|
||||
},
|
||||
} satisfies Model<"openai-completions">;
|
||||
|
||||
const stream = streamOpenAICompletions(compatibleModel, context, {
|
||||
apiKey: "sk-test",
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
await stream.result();
|
||||
|
||||
expect(mockOpenAIOptionsRef.payloads[0]).toMatchObject({
|
||||
thinking: { type: "enabled" },
|
||||
});
|
||||
expect(mockOpenAIOptionsRef.payloads[0]).not.toHaveProperty("reasoning_effort");
|
||||
});
|
||||
|
||||
it("configures the OpenAI SDK client with the host-built model fetch", async () => {
|
||||
mockOpenAIOptionsRef.options = [];
|
||||
mockChunksRef.chunks = [makeTextChunk("ok"), makeFinishChunk("stop")];
|
||||
|
|
|
|||
|
|
@ -755,7 +755,7 @@ function buildParams(
|
|||
};
|
||||
} else if (compat.thinkingFormat === "deepseek" && model.reasoning) {
|
||||
params.thinking = { type: options?.reasoningEffort ? "enabled" : "disabled" };
|
||||
if (options?.reasoningEffort) {
|
||||
if (options?.reasoningEffort && compat.supportsReasoningEffort) {
|
||||
params.reasoning_effort =
|
||||
model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ describe("model catalog normalization", () => {
|
|||
sendSessionIdHeader: false,
|
||||
supportsEagerToolInputStreaming: false,
|
||||
supportsLongCacheRetention: true,
|
||||
requiresReasoningContentOnAssistantMessages: true,
|
||||
supportsStore: "yes",
|
||||
thinkingFormat: "together",
|
||||
unknownFlag: true,
|
||||
|
|
@ -163,6 +164,7 @@ describe("model catalog normalization", () => {
|
|||
sendSessionIdHeader: false,
|
||||
supportsEagerToolInputStreaming: false,
|
||||
supportsLongCacheRetention: true,
|
||||
requiresReasoningContentOnAssistantMessages: true,
|
||||
thinkingFormat: "together",
|
||||
},
|
||||
status: "preview",
|
||||
|
|
|
|||
|
|
@ -383,6 +383,7 @@ function normalizeModelCatalogCompat(value: unknown): ModelCatalogCompatConfig |
|
|||
"requiresToolResultName",
|
||||
"requiresAssistantAfterToolResult",
|
||||
"requiresThinkingAsText",
|
||||
"requiresReasoningContentOnAssistantMessages",
|
||||
"zaiToolStream",
|
||||
"sendSessionAffinityHeaders",
|
||||
"sendSessionIdHeader",
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export type ModelCatalogCompatConfig = {
|
|||
requiresToolResultName?: boolean;
|
||||
requiresAssistantAfterToolResult?: boolean;
|
||||
requiresThinkingAsText?: boolean;
|
||||
requiresReasoningContentOnAssistantMessages?: boolean;
|
||||
openRouterRouting?: ModelCatalogOpenRouterRouting;
|
||||
vercelGatewayRouting?: ModelCatalogVercelGatewayRouting;
|
||||
zaiToolStream?: boolean;
|
||||
|
|
|
|||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
|
|
@ -969,6 +969,12 @@ importers:
|
|||
|
||||
extensions/lmstudio: {}
|
||||
|
||||
extensions/longcat:
|
||||
devDependencies:
|
||||
'@openclaw/plugin-sdk':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/plugin-sdk
|
||||
|
||||
extensions/lobster:
|
||||
dependencies:
|
||||
'@clawdbot/lobster':
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ function humanizeId(value) {
|
|||
["litellm", "LiteLLM"],
|
||||
["llm", "LLM"],
|
||||
["lmstudio", "LM Studio"],
|
||||
["longcat", "LongCat"],
|
||||
["mdns", "mDNS"],
|
||||
["minimax", "MiniMax"],
|
||||
["modelstudio", "Model Studio"],
|
||||
|
|
|
|||
|
|
@ -589,6 +589,49 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "@openclaw/longcat-provider",
|
||||
"description": "OpenClaw LongCat provider plugin",
|
||||
"source": "official",
|
||||
"kind": "provider",
|
||||
"openclaw": {
|
||||
"plugin": {
|
||||
"id": "longcat",
|
||||
"label": "LongCat"
|
||||
},
|
||||
"providers": [
|
||||
{
|
||||
"id": "longcat",
|
||||
"aliases": ["meituan-longcat"],
|
||||
"name": "LongCat",
|
||||
"docs": "/providers/longcat",
|
||||
"categories": ["cloud", "llm"],
|
||||
"envVars": ["LONGCAT_API_KEY"],
|
||||
"authChoices": [
|
||||
{
|
||||
"method": "api-key",
|
||||
"choiceId": "longcat-api-key",
|
||||
"choiceLabel": "LongCat API key",
|
||||
"groupId": "longcat",
|
||||
"groupLabel": "LongCat",
|
||||
"groupHint": "OpenAI-compatible LongCat API",
|
||||
"optionKey": "longcatApiKey",
|
||||
"cliFlag": "--longcat-api-key",
|
||||
"cliOption": "--longcat-api-key <key>",
|
||||
"cliDescription": "LongCat API key",
|
||||
"onboardingScopes": ["text-inference"]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/longcat-provider",
|
||||
"npmSpec": "@openclaw/longcat-provider",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "@openclaw/groq-provider",
|
||||
"description": "OpenClaw Groq media-understanding provider.",
|
||||
|
|
|
|||
|
|
@ -1632,6 +1632,7 @@ describe("official external plugin catalog", () => {
|
|||
["deepinfra", "@openclaw/deepinfra-provider"],
|
||||
["deepseek", "@openclaw/deepseek-provider"],
|
||||
["groq", "@openclaw/groq-provider"],
|
||||
["longcat", "@openclaw/longcat-provider"],
|
||||
["kilocode", "@openclaw/kilocode-provider"],
|
||||
["kimi", "@openclaw/kimi-provider"],
|
||||
["qianfan", "@openclaw/qianfan-provider"],
|
||||
|
|
@ -1752,6 +1753,19 @@ describe("official external plugin catalog", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("lists LongCat as an official external provider", () => {
|
||||
const longcat = expectCatalogEntry("longcat");
|
||||
|
||||
expect(resolveOfficialExternalPluginId(longcat)).toBe("longcat");
|
||||
expect(getOfficialExternalPluginCatalogEntry("meituan-longcat")).toBe(longcat);
|
||||
expect(resolveOfficialExternalPluginInstall(longcat)).toEqual({
|
||||
clawhubSpec: "clawhub:@openclaw/longcat-provider",
|
||||
npmSpec: "@openclaw/longcat-provider",
|
||||
defaultChoice: "npm",
|
||||
minHostVersion: ">=2026.6.8",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves external provider aliases beyond the primary provider id", () => {
|
||||
const qwen = expectCatalogEntry("qwen");
|
||||
|
||||
|
|
@ -1814,6 +1828,7 @@ describe("official external plugin catalog", () => {
|
|||
DEEPINFRA_API_KEY: "deepinfra-key",
|
||||
DEEPSEEK_API_KEY: "deepseek-key",
|
||||
GROQ_API_KEY: "groq-key",
|
||||
LONGCAT_API_KEY: "longcat-key",
|
||||
KILOCODE_API_KEY: "kilocode-key",
|
||||
KIMICODE_API_KEY: "kimi-key",
|
||||
KIMI_API_KEY: "moonshot-kimi-key",
|
||||
|
|
@ -1839,6 +1854,7 @@ describe("official external plugin catalog", () => {
|
|||
"groq",
|
||||
"kilocode",
|
||||
"kimi",
|
||||
"longcat",
|
||||
"moonshot",
|
||||
"qianfan",
|
||||
"qwen",
|
||||
|
|
@ -1849,6 +1865,7 @@ describe("official external plugin catalog", () => {
|
|||
"zai",
|
||||
]);
|
||||
expect(resolveOfficialExternalProviderPluginIdsForEnv({ GROQ_API_KEY: " " })).toEqual([]);
|
||||
expect(resolveOfficialExternalProviderPluginIdsForEnv({ LONGCAT_API_KEY: " " })).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps Tencent auth choices available through the cold-install auth catalog", () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue