From 2b51c255f85d1f3b7d11c48e9d25ae6c6d058f48 Mon Sep 17 00:00:00 2001 From: Sulaga <2500377984@qq.com> Date: Mon, 6 Jul 2026 12:29:48 +0800 Subject: [PATCH] feat(tencent): add Tencent Hy3 provider (TokenHub and TokenPlan) (#99076) Summary: - Merged feat(tencent): add Tencent Hy3 provider (TokenHub and TokenPlan) after ClawSweeper review. Automerge notes: - Ran the ClawSweeper repair loop before final review. - Included post-review commit in the final squash: fix(tencent): preserve TokenHub auth compatibility - Included post-review commit in the final squash: refactor(tencent): unify TokenPlan env/flag naming with TokenHub - Included post-review commit in the final squash: docs: refresh Tencent provider docs metadata - Included post-review commit in the final squash: fix: allow TokenPlan provider config overlays - Included post-review commit in the final squash: docs: dedupe Tencent provider glossary labels - Included post-review commit in the final squash: fix(tencent): repair TokenHub model defaults Validation: - ClawSweeper review passed for head 30c9fc130f26a038d1430c14e710eb77d99fb0d4. - Required merge gates passed before the squash merge. Prepared head SHA: 30c9fc130f26a038d1430c14e710eb77d99fb0d4 Review: https://github.com/openclaw/openclaw/pull/99076#issuecomment-4888527271 Co-authored-by: leisang Co-authored-by: Mason Huang Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> Approved-by: hxy91819 --- docs/.i18n/glossary.zh-CN.json | 4 +- docs/docs_map.md | 1 - docs/plugins/plugin-inventory.md | 2 +- docs/plugins/reference/tencent.md | 6 +- docs/providers/index.md | 2 +- docs/providers/tencent.md | 95 +++--- extensions/tencent/api.ts | 6 +- extensions/tencent/config-compat.test.ts | 112 ++++++ extensions/tencent/config-compat.ts | 120 +++++++ extensions/tencent/index.test.ts | 320 ++++++++++++++++++ extensions/tencent/index.ts | 107 ++++-- extensions/tencent/models.ts | 22 ++ extensions/tencent/onboard.ts | 40 ++- extensions/tencent/openclaw.plugin.json | 64 +++- extensions/tencent/package.json | 1 + extensions/tencent/provider-catalog.ts | 11 + extensions/tencent/provider-discovery.ts | 38 ++- extensions/tencent/setup-api.ts | 12 + extensions/tencent/stream.ts | 92 +++++ .../official-external-provider-catalog.json | 30 ++ src/config/config-misc.test.ts | 13 +- src/config/zod-schema.core.ts | 1 + src/infra/dotenv.ts | 1 + .../official-external-plugin-catalog.test.ts | 26 ++ src/plugins/setup-registry.tencent.test.ts | 38 +++ test/plugin-npm-runtime-build.test.ts | 15 + 26 files changed, 1080 insertions(+), 99 deletions(-) create mode 100644 extensions/tencent/config-compat.test.ts create mode 100644 extensions/tencent/config-compat.ts create mode 100644 extensions/tencent/index.test.ts create mode 100644 extensions/tencent/setup-api.ts create mode 100644 extensions/tencent/stream.ts create mode 100644 src/plugins/setup-registry.tencent.test.ts diff --git a/docs/.i18n/glossary.zh-CN.json b/docs/.i18n/glossary.zh-CN.json index 29ac8ae9623..69c9f84dcb0 100644 --- a/docs/.i18n/glossary.zh-CN.json +++ b/docs/.i18n/glossary.zh-CN.json @@ -992,8 +992,8 @@ "target": "轨迹导出" }, { - "source": "Tencent Cloud (TokenHub)", - "target": "腾讯云(TokenHub)" + "source": "Tencent Cloud (TokenHub / TokenPlan)", + "target": "腾讯云(TokenHub / TokenPlan)" }, { "source": "Codex Harness Context Engine Port", diff --git a/docs/docs_map.md b/docs/docs_map.md index a6cf3a2d814..6d2261f0133 100644 --- a/docs/docs_map.md +++ b/docs/docs_map.md @@ -7813,7 +7813,6 @@ Do not edit it by hand; run `pnpm docs:map:gen`. - H2: Quick start - H2: Non-interactive setup - H2: Built-in catalog - - H2: Tiered pricing - H2: Advanced configuration - H2: Related diff --git a/docs/plugins/plugin-inventory.md b/docs/plugins/plugin-inventory.md index 1e8133abb37..7380f8da729 100644 --- a/docs/plugins/plugin-inventory.md +++ b/docs/plugins/plugin-inventory.md @@ -291,7 +291,7 @@ Each entry lists the package, distribution route, and description. - **[tavily](/plugins/reference/tavily)** (`@openclaw/tavily-plugin`) - npm; ClawHub: `clawhub:@openclaw/tavily-plugin`. Adds agent-callable tools. Adds web search provider support. -- **[tencent](/plugins/reference/tencent)** (`@openclaw/tencent-provider`) - npm; ClawHub: `clawhub:@openclaw/tencent-provider`. Adds Tencent TokenHub model provider support to OpenClaw. +- **[tencent](/plugins/reference/tencent)** (`@openclaw/tencent-provider`) - npm; ClawHub: `clawhub:@openclaw/tencent-provider`. Adds Tencent TokenHub and TokenPlan model provider support to OpenClaw. - **[tlon](/plugins/reference/tlon)** (`@openclaw/tlon`) - npm; ClawHub. OpenClaw Tlon/Urbit channel plugin for chat workflows. diff --git a/docs/plugins/reference/tencent.md b/docs/plugins/reference/tencent.md index 7921212b5d6..5324bc01503 100644 --- a/docs/plugins/reference/tencent.md +++ b/docs/plugins/reference/tencent.md @@ -1,5 +1,5 @@ --- -summary: "Adds Tencent TokenHub model provider support to OpenClaw." +summary: "Adds Tencent Hy3 model provider support (TokenHub and TokenPlan) to OpenClaw." read_when: - You are installing, configuring, or auditing the tencent plugin title: "Tencent plugin" @@ -7,7 +7,7 @@ title: "Tencent plugin" # Tencent plugin -Adds Tencent TokenHub model provider support to OpenClaw. +Adds Tencent TokenHub and TokenPlan model provider support to OpenClaw. ## Distribution @@ -16,7 +16,7 @@ Adds Tencent TokenHub model provider support to OpenClaw. ## Surface -providers: tencent-tokenhub +providers: tencent-tokenhub, tencent-tokenplan ## Related docs diff --git a/docs/providers/index.md b/docs/providers/index.md index b9f05266556..01b676b095b 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -72,7 +72,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi - [SGLang (local models)](/providers/sglang) - [StepFun](/providers/stepfun) - [Synthetic](/providers/synthetic) -- [Tencent Cloud (TokenHub)](/providers/tencent) +- [Tencent Cloud (TokenHub / TokenPlan)](/providers/tencent) - [Together AI](/providers/together) - [Venice (Venice AI, privacy-focused)](/providers/venice) - [Vercel AI Gateway](/providers/vercel-ai-gateway) diff --git a/docs/providers/tencent.md b/docs/providers/tencent.md index df730073f96..1e20a75073b 100644 --- a/docs/providers/tencent.md +++ b/docs/providers/tencent.md @@ -1,50 +1,61 @@ --- -summary: "Tencent Cloud TokenHub setup for Hy3 preview" -title: "Tencent Cloud (TokenHub)" +summary: "Tencent Cloud TokenHub and TokenPlan setup for hy3" +title: "Tencent Cloud (TokenHub / TokenPlan)" read_when: - - You want to use Tencent Hy3 preview with OpenClaw - - You need the TokenHub API key setup + - You want to use Tencent hy3 with OpenClaw + - You need the TokenHub or TokenPlan API key setup --- -Install the official Tencent Cloud provider plugin to access Tencent Hy3 preview through the TokenHub endpoint (`tencent-tokenhub`) using an OpenAI-compatible API. +Install the official Tencent Cloud provider plugin to access Tencent Hy3 through two endpoints — TokenHub (`tencent-tokenhub`) and TokenPlan (`tencent-tokenplan`) — using an OpenAI-compatible API. -| Property | Value | -| --------------- | ---------------------------------------- | -| Provider id | `tencent-tokenhub` | -| Package | `@openclaw/tencent-provider` | -| Auth env var | `TOKENHUB_API_KEY` | -| Onboarding flag | `--auth-choice tokenhub-api-key` | -| Direct CLI flag | `--tokenhub-api-key ` | -| API | OpenAI-compatible (`openai-completions`) | -| Base URL | `https://tokenhub.tencentmaas.com/v1` | -| Default model | `tencent-tokenhub/hy3-preview` | +| Property | Value | +| ------------------------- | ----------------------------------------------------- | +| Provider ids | `tencent-tokenhub`, `tencent-tokenplan` | +| Package | `@openclaw/tencent-provider` | +| TokenHub auth env var | `TOKENHUB_API_KEY` | +| TokenPlan auth env var | `TOKENPLAN_API_KEY` | +| TokenHub onboarding flag | `--auth-choice tokenhub-api-key` | +| TokenPlan onboarding flag | `--auth-choice tokenplan-api-key` | +| TokenHub direct CLI flag | `--tokenhub-api-key ` | +| TokenPlan direct CLI flag | `--tokenplan-api-key ` | +| API | OpenAI-compatible (`openai-completions`) | +| TokenHub base URL | `https://tokenhub.tencentmaas.com/v1` | +| TokenHub global base URL | `https://tokenhub-intl.tencentmaas.com/v1` (override) | +| TokenPlan base URL | `https://api.lkeap.cloud.tencent.com/plan/v3` | +| Default model | `tencent-tokenhub/hy3` | ## Quick start - - ```bash - openclaw plugins install @openclaw/tencent-provider - ``` - - - Create an API key in Tencent Cloud TokenHub. If you choose a limited access scope for the key, include **Hy3 preview** in the allowed models. + + Create an API key for Tencent Cloud TokenHub and TokenPlan. If you choose a limited access scope for the key, include **hy3** (and **hy3 preview** if you plan to use it on TokenHub) in the allowed models. -```bash Onboarding +```bash TokenHub onboarding openclaw onboard --auth-choice tokenhub-api-key ``` -```bash Direct flag +```bash TokenHub direct flag openclaw onboard --non-interactive \ --auth-choice tokenhub-api-key \ --tokenhub-api-key "$TOKENHUB_API_KEY" ``` +```bash TokenPlan onboarding +openclaw onboard --auth-choice tokenplan-api-key +``` + +```bash TokenPlan direct flag +openclaw onboard --non-interactive \ + --auth-choice tokenplan-api-key \ + --tokenplan-api-key "$TOKENPLAN_API_KEY" +``` + ```bash Env only export TOKENHUB_API_KEY=... +export TOKENPLAN_API_KEY=... ``` @@ -53,6 +64,7 @@ export TOKENHUB_API_KEY=... ```bash openclaw models list --provider tencent-tokenhub + openclaw models list --provider tencent-tokenplan ``` @@ -60,12 +72,21 @@ export TOKENHUB_API_KEY=... ## Non-interactive setup ```bash +# TokenHub openclaw onboard --non-interactive \ --mode local \ --auth-choice tokenhub-api-key \ --tokenhub-api-key "$TOKENHUB_API_KEY" \ --skip-health \ --accept-risk + +# TokenPlan +openclaw onboard --non-interactive \ + --mode local \ + --auth-choice tokenplan-api-key \ + --tokenplan-api-key "$TOKENPLAN_API_KEY" \ + --skip-health \ + --accept-risk ``` @@ -74,28 +95,18 @@ openclaw onboard --non-interactive \ ## Built-in catalog -| Model ref | Name | Input | Context | Max output | Notes | -| ------------------------------ | ---------------------- | ----- | ------- | ---------- | -------------------------- | -| `tencent-tokenhub/hy3-preview` | Hy3 preview (TokenHub) | text | 256,000 | 64,000 | Default; reasoning-enabled | +| Model ref | Name | Input | Context | Max output | Notes | +| ------------------------------ | ---------------------- | ----- | ------- | ---------- | ----------------- | +| `tencent-tokenhub/hy3-preview` | hy3 preview (TokenHub) | text | 256,000 | 64,000 | reasoning-enabled | +| `tencent-tokenhub/hy3` | hy3 (TokenHub) | text | 256,000 | 64,000 | reasoning-enabled | +| `tencent-tokenplan/hy3` | hy3 (TokenPlan) | text | 256,000 | 64,000 | reasoning-enabled | -Hy3 preview is Tencent Hunyuan's large MoE language model for reasoning, long-context instruction following, code, and agent workflows. It supports standard chat-completions tool calling plus `reasoning_effort`. +hy3 is Tencent Hunyuan's large MoE language model for reasoning, long-context instruction following, code, and agent workflows. Tencent's OpenAI-compatible examples use `hy3` as the model id and support standard chat-completions tool calling plus `reasoning_effort`. - The model id is `hy3-preview`. Do not confuse it with Tencent's `HY-3D-*` models, which are 3D generation APIs and are not the OpenClaw chat model configured by this provider. + The model id is `hy3`. Do not confuse it with Tencent's `HY-3D-*` models, which are 3D generation APIs and are not the OpenClaw chat model configured by this provider. -## Tiered pricing - -The provider catalog ships tiered cost metadata that scales with input window length, so cost estimates are populated without manual overrides. - -| Input tokens range | Input rate | Output rate | Cache read | -| ------------------ | ---------- | ----------- | ---------- | -| 0 - 16,000 | 0.176 | 0.587 | 0.059 | -| 16,000 - 32,000 | 0.235 | 0.939 | 0.088 | -| 32,000+ | 0.293 | 1.173 | 0.117 | - -Rates are per million tokens in USD as advertised by Tencent. Override pricing under `models.providers.tencent-tokenhub` only when you need a different surface. - ## Advanced configuration @@ -109,7 +120,7 @@ Rates are per million tokens in USD as advertised by Tencent. Override pricing u - If the Gateway runs as a managed service (launchd, systemd, Docker), `TOKENHUB_API_KEY` must be visible to that process. Set it in `~/.openclaw/.env` or via `env.shellEnv` so launchd, systemd, or Docker exec environments can read it. + If the Gateway runs as a managed service (launchd, systemd, Docker), `TOKENHUB_API_KEY` and `TOKENPLAN_API_KEY` must be visible to that process. Set them in `~/.openclaw/.env` or via `env.shellEnv` so launchd, systemd, or Docker exec environments can read them. Keys exported only in an interactive shell are not visible to managed gateway processes. Use the env file or config seam for persistent availability. diff --git a/extensions/tencent/api.ts b/extensions/tencent/api.ts index a43cc49801f..36711a35229 100644 --- a/extensions/tencent/api.ts +++ b/extensions/tencent/api.ts @@ -1,8 +1,12 @@ // Tencent API module exposes the plugin public contract. export { buildTokenHubModelDefinition, + buildTokenPlanModelDefinition, TOKENHUB_BASE_URL, TOKENHUB_MODEL_CATALOG, TOKENHUB_PROVIDER_ID, + TOKENPLAN_BASE_URL, + TOKENPLAN_MODEL_CATALOG, + TOKENPLAN_PROVIDER_ID, } from "./models.js"; -export { buildTokenHubProvider } from "./provider-catalog.js"; +export { buildTokenHubProvider, buildTokenPlanProvider } from "./provider-catalog.js"; diff --git a/extensions/tencent/config-compat.test.ts b/extensions/tencent/config-compat.test.ts new file mode 100644 index 00000000000..a6e3376ec02 --- /dev/null +++ b/extensions/tencent/config-compat.test.ts @@ -0,0 +1,112 @@ +// Tencent tests cover config compatibility repair behavior. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { describe, expect, it } from "vitest"; +import { + migrateTencentTokenHubModelDefaults, + TENCENT_TOKENHUB_DEFAULT_MODEL_REF, + TENCENT_TOKENHUB_PREVIEW_MODEL_REF, +} from "./config-compat.js"; + +describe("Tencent config compatibility", () => { + it("adds the stable TokenHub model and makes it primary for old preview defaults", () => { + const config = { + agents: { + defaults: { + model: { + primary: TENCENT_TOKENHUB_PREVIEW_MODEL_REF, + fallbacks: ["openai/gpt-5.5"], + }, + models: { + [TENCENT_TOKENHUB_PREVIEW_MODEL_REF]: { + alias: "Preview", + }, + }, + }, + }, + } as OpenClawConfig; + + const result = migrateTencentTokenHubModelDefaults(config); + + expect(result.changes).toEqual([ + `Updated Tencent TokenHub agent model defaults to include ${TENCENT_TOKENHUB_DEFAULT_MODEL_REF} and ${TENCENT_TOKENHUB_PREVIEW_MODEL_REF}.`, + `Changed Tencent TokenHub primary default from ${TENCENT_TOKENHUB_PREVIEW_MODEL_REF} to ${TENCENT_TOKENHUB_DEFAULT_MODEL_REF}.`, + ]); + expect(result.config.agents?.defaults?.model).toEqual({ + primary: TENCENT_TOKENHUB_DEFAULT_MODEL_REF, + fallbacks: ["openai/gpt-5.5"], + }); + expect(result.config.agents?.defaults?.models).toEqual({ + [TENCENT_TOKENHUB_PREVIEW_MODEL_REF]: { + alias: "Preview", + }, + [TENCENT_TOKENHUB_DEFAULT_MODEL_REF]: { + alias: "Hy3 (TokenHub)", + }, + }); + expect(config.agents?.defaults?.models).not.toHaveProperty(TENCENT_TOKENHUB_DEFAULT_MODEL_REF); + }); + + it("adds the preview TokenHub model for hy3-only intermediate configs", () => { + const config = { + agents: { + defaults: { + model: TENCENT_TOKENHUB_DEFAULT_MODEL_REF, + models: { + [TENCENT_TOKENHUB_DEFAULT_MODEL_REF]: {}, + }, + }, + }, + } as OpenClawConfig; + + const result = migrateTencentTokenHubModelDefaults(config); + + expect(result.config.agents?.defaults?.model).toBe(TENCENT_TOKENHUB_DEFAULT_MODEL_REF); + expect(result.config.agents?.defaults?.models).toEqual({ + [TENCENT_TOKENHUB_DEFAULT_MODEL_REF]: { + alias: "Hy3 (TokenHub)", + }, + [TENCENT_TOKENHUB_PREVIEW_MODEL_REF]: { + alias: "Hy3 preview (TokenHub)", + }, + }); + }); + + it("does not create a model allowlist when TokenHub models are not already configured", () => { + const config = { + models: { + providers: { + "tencent-tokenhub": { + baseUrl: "https://tokenhub.tencentmaas.com/v1", + models: [], + }, + }, + }, + } as OpenClawConfig; + + const result = migrateTencentTokenHubModelDefaults(config); + + expect(result).toEqual({ config, changes: [] }); + }); + + it("does not report changes after TokenHub defaults are already repaired", () => { + const config = { + agents: { + defaults: { + model: { primary: TENCENT_TOKENHUB_DEFAULT_MODEL_REF }, + models: { + [TENCENT_TOKENHUB_DEFAULT_MODEL_REF]: { + alias: "Hy3 (TokenHub)", + }, + [TENCENT_TOKENHUB_PREVIEW_MODEL_REF]: { + alias: "Hy3 preview (TokenHub)", + }, + }, + }, + }, + } as OpenClawConfig; + + const result = migrateTencentTokenHubModelDefaults(config); + + expect(result).toEqual({ config, changes: [] }); + }); +}); diff --git a/extensions/tencent/config-compat.ts b/extensions/tencent/config-compat.ts new file mode 100644 index 00000000000..2e7b54ee866 --- /dev/null +++ b/extensions/tencent/config-compat.ts @@ -0,0 +1,120 @@ +// Tencent config compatibility repairs shipped TokenHub model allowlists. +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; + +export const TENCENT_TOKENHUB_DEFAULT_MODEL_REF = "tencent-tokenhub/hy3"; +export const TENCENT_TOKENHUB_PREVIEW_MODEL_REF = "tencent-tokenhub/hy3-preview"; + +const TOKENHUB_DEFAULT_ALIAS = "Hy3 (TokenHub)"; +const TOKENHUB_PREVIEW_ALIAS = "Hy3 preview (TokenHub)"; + +type AgentDefaults = NonNullable["defaults"]>; +type AgentDefaultModel = AgentDefaults["model"]; +type AgentModelEntry = NonNullable[string]; + +function isTokenHubModelMapConfigured(models: Record): boolean { + return ( + Object.hasOwn(models, TENCENT_TOKENHUB_DEFAULT_MODEL_REF) || + Object.hasOwn(models, TENCENT_TOKENHUB_PREVIEW_MODEL_REF) + ); +} + +function withDefaultAlias(entry: AgentModelEntry | undefined, alias: string): AgentModelEntry { + return { + ...entry, + alias: entry?.alias ?? alias, + }; +} + +function needsDefaultAlias(entry: AgentModelEntry | undefined): boolean { + return entry?.alias === undefined; +} + +function migrateDefaultModel(model: AgentDefaultModel): { + model: AgentDefaultModel; + changed: boolean; +} { + if (model === TENCENT_TOKENHUB_PREVIEW_MODEL_REF) { + return { + model: { primary: TENCENT_TOKENHUB_DEFAULT_MODEL_REF }, + changed: true, + }; + } + if ( + model && + typeof model === "object" && + "primary" in model && + model.primary === TENCENT_TOKENHUB_PREVIEW_MODEL_REF + ) { + return { + model: { + ...model, + primary: TENCENT_TOKENHUB_DEFAULT_MODEL_REF, + }, + changed: true, + }; + } + return { model, changed: false }; +} + +export function migrateTencentTokenHubModelDefaults(cfg: OpenClawConfig): { + config: OpenClawConfig; + changes: string[]; +} { + const existingModels = cfg.agents?.defaults?.models; + if (!existingModels || !isTokenHubModelMapConfigured(existingModels)) { + return { config: cfg, changes: [] }; + } + + const needsDefaultRepair = + !Object.hasOwn(existingModels, TENCENT_TOKENHUB_DEFAULT_MODEL_REF) || + needsDefaultAlias(existingModels[TENCENT_TOKENHUB_DEFAULT_MODEL_REF]); + const needsPreviewRepair = + !Object.hasOwn(existingModels, TENCENT_TOKENHUB_PREVIEW_MODEL_REF) || + needsDefaultAlias(existingModels[TENCENT_TOKENHUB_PREVIEW_MODEL_REF]); + const migratedModel = migrateDefaultModel(cfg.agents?.defaults?.model); + if (!needsDefaultRepair && !needsPreviewRepair && !migratedModel.changed) { + return { config: cfg, changes: [] }; + } + + const nextModels = { + ...existingModels, + [TENCENT_TOKENHUB_DEFAULT_MODEL_REF]: withDefaultAlias( + existingModels[TENCENT_TOKENHUB_DEFAULT_MODEL_REF], + TOKENHUB_DEFAULT_ALIAS, + ), + [TENCENT_TOKENHUB_PREVIEW_MODEL_REF]: withDefaultAlias( + existingModels[TENCENT_TOKENHUB_PREVIEW_MODEL_REF], + TOKENHUB_PREVIEW_ALIAS, + ), + }; + + const nextConfig: OpenClawConfig = { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...cfg.agents?.defaults, + models: nextModels, + ...(migratedModel.model !== undefined ? { model: migratedModel.model } : undefined), + }, + }, + }; + + const changes = [ + `Updated Tencent TokenHub agent model defaults to include ${TENCENT_TOKENHUB_DEFAULT_MODEL_REF} and ${TENCENT_TOKENHUB_PREVIEW_MODEL_REF}.`, + ]; + if (migratedModel.changed) { + changes.push( + `Changed Tencent TokenHub primary default from ${TENCENT_TOKENHUB_PREVIEW_MODEL_REF} to ${TENCENT_TOKENHUB_DEFAULT_MODEL_REF}.`, + ); + } + + return { config: nextConfig, changes }; +} + +export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): { + config: OpenClawConfig; + changes: string[]; +} { + return migrateTencentTokenHubModelDefaults(cfg); +} diff --git a/extensions/tencent/index.test.ts b/extensions/tencent/index.test.ts new file mode 100644 index 00000000000..7ecf8689b4a --- /dev/null +++ b/extensions/tencent/index.test.ts @@ -0,0 +1,320 @@ +// Tencent tests cover index plugin behavior. +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { Context, Model } from "openclaw/plugin-sdk/llm"; +import { + registerProviderPlugin, + requireRegisteredProvider, + resolveProviderPluginChoice, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime"; +import { describe, expect, it } from "vitest"; +import { runSingleProviderCatalog } from "../test-support/provider-model-test-helpers.js"; +import tencentPlugin from "./index.js"; + +type OpenAICompletionsModel = Model<"openai-completions">; + +const registerTencentPlugin = () => + registerProviderPlugin({ + plugin: tencentPlugin, + id: "tencent", + name: "Tencent Cloud Provider", + }); + +async function getTokenHubProvider() { + const { providers } = await registerTencentPlugin(); + return requireRegisteredProvider(providers, "tencent-tokenhub"); +} + +async function getTokenPlanProvider() { + const { providers } = await registerTencentPlugin(); + return requireRegisteredProvider(providers, "tencent-tokenplan"); +} + +function hyReasoningModel(params: { + provider: "tencent-tokenhub" | "tencent-tokenplan"; + id: "hy3" | "hy3-preview"; + baseUrl: string; + supportedReasoningEfforts?: string[]; +}): OpenAICompletionsModel { + return { + provider: params.provider, + id: params.id, + name: params.id, + api: "openai-completions", + baseUrl: params.baseUrl, + reasoning: true, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 256_000, + maxTokens: 64_000, + compat: { + supportsUsageInStreaming: true, + supportsReasoningEffort: true, + supportedReasoningEfforts: params.supportedReasoningEfforts ?? ["none", "high"], + }, + } as OpenAICompletionsModel; +} + +function captureTencentPayload(params: { + provider: Pick>, "wrapStreamFn">; + model: OpenAICompletionsModel; + reasoning: string; +}) { + let captured: Record | undefined; + const baseStreamFn: StreamFn = (_model, context, options) => { + const payload = buildOpenAICompletionsParams(_model as OpenAICompletionsModel, context, options); + options?.onPayload?.(payload, _model); + captured = payload; + return {} as ReturnType; + }; + const wrapped = params.provider.wrapStreamFn?.({ + streamFn: baseStreamFn, + provider: params.model.provider, + modelId: params.model.id, + model: params.model, + thinkingLevel: "high", + }); + if (!wrapped) { + throw new Error("expected Tencent provider stream wrapper"); + } + void wrapped( + params.model, + { messages: [] } as never, + { + reasoning: params.reasoning, + } as never, + ); + return captured; +} + +describe("tencent provider plugin", () => { + it("registers Tencent TokenHub api-key auth metadata", async () => { + const { providers } = await registerTencentPlugin(); + const provider = requireRegisteredProvider(providers, "tencent-tokenhub"); + const resolved = resolveProviderPluginChoice({ + providers, + choice: "tokenhub-api-key", + }); + + expect(provider.id).toBe("tencent-tokenhub"); + expect(provider.label).toBe("Tencent TokenHub"); + expect(provider.envVars).toEqual(["TOKENHUB_API_KEY"]); + expect(provider.auth).toHaveLength(1); + if (!resolved) { + throw new Error("expected Tencent TokenHub api-key auth choice"); + } + expect(resolved.provider.id).toBe("tencent-tokenhub"); + expect(resolved.method.id).toBe("api-key"); + }); + + it("registers Tencent TokenPlan api-key auth metadata", async () => { + const { providers } = await registerTencentPlugin(); + const provider = requireRegisteredProvider(providers, "tencent-tokenplan"); + const resolved = resolveProviderPluginChoice({ + providers, + choice: "tokenplan-api-key", + }); + + expect(provider.id).toBe("tencent-tokenplan"); + expect(provider.label).toBe("Tencent TokenPlan"); + expect(provider.envVars).toEqual(["TOKENPLAN_API_KEY"]); + expect(provider.auth).toHaveLength(1); + if (!resolved) { + throw new Error("expected Tencent TokenPlan api-key auth choice"); + } + expect(resolved.provider.id).toBe("tencent-tokenplan"); + expect(resolved.method.id).toBe("api-key"); + }); + + it("builds the static Tencent TokenHub model catalog with reasoning flags", async () => { + const provider = await getTokenHubProvider(); + const catalogProvider = await runSingleProviderCatalog(provider); + + expect(catalogProvider.api).toBe("openai-completions"); + expect(catalogProvider.baseUrl).toBe("https://tokenhub.tencentmaas.com/v1"); + + const modelIds = catalogProvider.models?.map((m) => m.id); + expect(modelIds).toContain("hy3"); + expect(modelIds).toContain("hy3-preview"); + + const hy3 = catalogProvider.models?.find((m) => m.id === "hy3"); + expect(hy3?.reasoning).toBe(true); + expect(hy3?.compat?.supportsReasoningEffort).toBe(true); + expect(hy3?.compat?.supportedReasoningEfforts).toEqual(["none", "high"]); + + const hy3Preview = catalogProvider.models?.find((m) => m.id === "hy3-preview"); + expect(hy3Preview?.reasoning).toBe(true); + expect(hy3Preview?.compat?.supportsReasoningEffort).toBe(true); + expect(hy3Preview?.compat?.supportedReasoningEfforts).toEqual(["none", "low", "high"]); + }); + + it("builds the static Tencent TokenPlan model catalog with reasoning flags", async () => { + const provider = await getTokenPlanProvider(); + const catalogProvider = await runSingleProviderCatalog(provider); + + expect(catalogProvider.api).toBe("openai-completions"); + expect(catalogProvider.baseUrl).toBe("https://api.lkeap.cloud.tencent.com/plan/v3"); + + const modelIds = catalogProvider.models?.map((m) => m.id); + expect(modelIds).toEqual(["hy3"]); + + const hy3 = catalogProvider.models?.find((m) => m.id === "hy3"); + expect(hy3?.reasoning).toBe(true); + expect(hy3?.compat?.supportsReasoningEffort).toBe(true); + expect(hy3?.compat?.supportedReasoningEfforts).toEqual(["none", "high"]); + }); + + it("injects reasoning_effort into TokenPlan hy3 chat-completions payload", async () => { + const model = hyReasoningModel({ + provider: "tencent-tokenplan", + id: "hy3", + baseUrl: "https://api.lkeap.cloud.tencent.com/plan/v3", + }); + const context = { messages: [{ role: "user", content: "hi", timestamp: 1 }] } as Context; + + const payload = buildOpenAICompletionsParams(model, context, { + reasoning: "high", + } as never); + + expect(payload.model).toBe("hy3"); + expect(payload.reasoning_effort).toBe("high"); + }); + + it("emits reasoning_effort=high when high effort is requested for TokenHub hy3", async () => { + const model = hyReasoningModel({ + provider: "tencent-tokenhub", + id: "hy3", + baseUrl: "https://tokenhub.tencentmaas.com/v1", + }); + const context = { messages: [{ role: "user", content: "hi", timestamp: 1 }] } as Context; + + const payload = buildOpenAICompletionsParams(model, context, { + reasoning: "high", + } as never); + + expect(payload.reasoning_effort).toBe("high"); + }); + + it("emits reasoning_effort=none when none effort is requested for TokenHub hy3", async () => { + const model = hyReasoningModel({ + provider: "tencent-tokenhub", + id: "hy3", + baseUrl: "https://tokenhub.tencentmaas.com/v1", + }); + const context = { messages: [{ role: "user", content: "hi", timestamp: 1 }] } as Context; + + const payload = buildOpenAICompletionsParams(model, context, { + reasoning: "none", + } as never); + + expect(payload.reasoning_effort).toBe("none"); + }); + + it("defaults hy3-preview reasoning_effort to high when no effort is provided", async () => { + const model = hyReasoningModel({ + provider: "tencent-tokenhub", + id: "hy3-preview", + baseUrl: "https://tokenhub.tencentmaas.com/v1", + supportedReasoningEfforts: ["none", "low", "high"], + }); + const context = { messages: [{ role: "user", content: "hi", timestamp: 1 }] } as Context; + + const payload = buildOpenAICompletionsParams(model, context, undefined); + + expect(payload.reasoning_effort).toBe("high"); + }); + + it("preserves low reasoning_effort for TokenHub hy3-preview", async () => { + const provider = await getTokenHubProvider(); + const model = hyReasoningModel({ + provider: "tencent-tokenhub", + id: "hy3-preview", + baseUrl: "https://tokenhub.tencentmaas.com/v1", + supportedReasoningEfforts: ["none", "low", "high"], + }); + + const payload = captureTencentPayload({ + provider, + model, + reasoning: "low", + }); + + expect(payload?.reasoning_effort).toBe("low"); + }); + + it("keeps TokenHub hy3 explicit high and none reasoning_effort unchanged", async () => { + const provider = await getTokenHubProvider(); + const model = hyReasoningModel({ + provider: "tencent-tokenhub", + id: "hy3", + baseUrl: "https://tokenhub.tencentmaas.com/v1", + }); + + const highPayload = captureTencentPayload({ + provider, + model, + reasoning: "high", + }); + const nonePayload = captureTencentPayload({ + provider, + model, + reasoning: "none", + }); + + expect(highPayload?.reasoning_effort).toBe("high"); + expect(nonePayload?.reasoning_effort).toBe("none"); + }); + + it("keeps minimal reasoning enabled for TokenHub and TokenPlan hy3", async () => { + const tokenHubProvider = await getTokenHubProvider(); + const tokenPlanProvider = await getTokenPlanProvider(); + const tokenHubModel = hyReasoningModel({ + provider: "tencent-tokenhub", + id: "hy3", + baseUrl: "https://tokenhub.tencentmaas.com/v1", + }); + const tokenPlanModel = hyReasoningModel({ + provider: "tencent-tokenplan", + id: "hy3", + baseUrl: "https://api.lkeap.cloud.tencent.com/plan/v3", + }); + + const tokenHubPayload = captureTencentPayload({ + provider: tokenHubProvider, + model: tokenHubModel, + reasoning: "minimal", + }); + const tokenPlanPayload = captureTencentPayload({ + provider: tokenPlanProvider, + model: tokenPlanModel, + reasoning: "minimal", + }); + + expect(tokenHubPayload?.reasoning_effort).toBe("high"); + expect(tokenPlanPayload?.reasoning_effort).toBe("high"); + }); + + it("keeps TokenHub hy3-preview unsupported efforts on the model fallback path", async () => { + const provider = await getTokenHubProvider(); + const model = hyReasoningModel({ + provider: "tencent-tokenhub", + id: "hy3-preview", + baseUrl: "https://tokenhub.tencentmaas.com/v1", + supportedReasoningEfforts: ["none", "low", "high"], + }); + + const minimalPayload = captureTencentPayload({ + provider, + model, + reasoning: "minimal", + }); + const mediumPayload = captureTencentPayload({ + provider, + model, + reasoning: "medium", + }); + + expect(minimalPayload?.reasoning_effort).toBe("low"); + expect(mediumPayload?.reasoning_effort).toBe("low"); + }); +}); diff --git a/extensions/tencent/index.ts b/extensions/tencent/index.ts index f41efd09615..36380bd88b3 100644 --- a/extensions/tencent/index.ts +++ b/extensions/tencent/index.ts @@ -2,9 +2,20 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildSingleProviderApiKeyCatalog } from "openclaw/plugin-sdk/provider-catalog-shared"; -import { TOKENHUB_MODEL_CATALOG, TOKENHUB_PROVIDER_ID } from "./models.js"; -import { applyTokenHubConfig, TOKENHUB_DEFAULT_MODEL_REF } from "./onboard.js"; -import { buildTokenHubProvider } from "./provider-catalog.js"; +import { + TOKENHUB_MODEL_CATALOG, + TOKENHUB_PROVIDER_ID, + TOKENPLAN_MODEL_CATALOG, + TOKENPLAN_PROVIDER_ID, +} from "./models.js"; +import { + applyTokenHubConfig, + TOKENHUB_DEFAULT_MODEL_REF, + applyTokenPlanConfig, + TOKENPLAN_DEFAULT_MODEL_REF, +} from "./onboard.js"; +import { buildTokenHubProvider, buildTokenPlanProvider } from "./provider-catalog.js"; +import { wrapTencentProviderStream } from "./stream.js"; function buildStaticCatalogEntries(providerId: string, catalog: typeof TOKENHUB_MODEL_CATALOG) { return catalog.map((entry) => ({ @@ -17,38 +28,40 @@ function buildStaticCatalogEntries(providerId: string, catalog: typeof TOKENHUB_ })); } +function createTokenHubApiKeyAuthMethod() { + return createProviderApiKeyAuthMethod({ + providerId: TOKENHUB_PROVIDER_ID, + methodId: "api-key", + label: "Tencent TokenHub", + hint: "Hy via Tencent TokenHub Gateway", + optionKey: "tokenhubApiKey", + flagName: "--tokenhub-api-key", + envVar: "TOKENHUB_API_KEY", + promptMessage: "Enter Tencent TokenHub API key", + defaultModel: TOKENHUB_DEFAULT_MODEL_REF, + expectedProviders: [TOKENHUB_PROVIDER_ID], + applyConfig: applyTokenHubConfig, + wizard: { + choiceId: "tokenhub-api-key", + choiceLabel: "Tencent TokenHub", + groupId: "tencent", + groupLabel: "Tencent Cloud", + groupHint: "Tencent TokenHub", + }, + }); +} + export default definePluginEntry({ id: "tencent", name: "Tencent Cloud Provider", - description: "Bundled Tencent Cloud provider plugin (TokenHub)", + description: "Bundled Tencent Cloud provider plugin (TokenHub, TokenPlan)", register(api) { api.registerProvider({ id: TOKENHUB_PROVIDER_ID, label: "Tencent TokenHub", docsPath: "/providers/tencent", envVars: ["TOKENHUB_API_KEY"], - auth: [ - createProviderApiKeyAuthMethod({ - providerId: TOKENHUB_PROVIDER_ID, - methodId: "api-key", - label: "Tencent TokenHub", - hint: "Hy via Tencent TokenHub Gateway", - optionKey: "tokenhubApiKey", - flagName: "--tokenhub-api-key", - envVar: "TOKENHUB_API_KEY", - promptMessage: "Enter Tencent TokenHub API key", - defaultModel: TOKENHUB_DEFAULT_MODEL_REF, - expectedProviders: [TOKENHUB_PROVIDER_ID], - applyConfig: (cfg) => applyTokenHubConfig(cfg), - wizard: { - choiceId: "tokenhub-api-key", - choiceLabel: "Tencent TokenHub", - groupId: "tencent", - groupLabel: "Tencent Cloud", - groupHint: "Tencent TokenHub", - }, - }), - ], + auth: [createTokenHubApiKeyAuthMethod()], catalog: { order: "simple", run: (ctx) => @@ -60,6 +73,48 @@ export default definePluginEntry({ }, augmentModelCatalog: () => buildStaticCatalogEntries(TOKENHUB_PROVIDER_ID, TOKENHUB_MODEL_CATALOG), + wrapStreamFn: wrapTencentProviderStream, + }); + + api.registerProvider({ + id: TOKENPLAN_PROVIDER_ID, + label: "Tencent TokenPlan", + docsPath: "/providers/tencent", + envVars: ["TOKENPLAN_API_KEY"], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: TOKENPLAN_PROVIDER_ID, + methodId: "api-key", + label: "Tencent TokenPlan", + hint: "Hy via Tencent TokenPlan Gateway", + optionKey: "tokenplanApiKey", + flagName: "--tokenplan-api-key", + envVar: "TOKENPLAN_API_KEY", + promptMessage: "Enter Tencent TokenPlan API key", + defaultModel: TOKENPLAN_DEFAULT_MODEL_REF, + expectedProviders: [TOKENPLAN_PROVIDER_ID], + applyConfig: applyTokenPlanConfig, + wizard: { + choiceId: "tokenplan-api-key", + choiceLabel: "Tencent TokenPlan", + groupId: "tencent", + groupLabel: "Tencent Cloud", + groupHint: "Tencent TokenPlan", + }, + }), + ], + catalog: { + order: "simple", + run: (ctx) => + buildSingleProviderApiKeyCatalog({ + ctx, + providerId: TOKENPLAN_PROVIDER_ID, + buildProvider: buildTokenPlanProvider, + }), + }, + augmentModelCatalog: () => + buildStaticCatalogEntries(TOKENPLAN_PROVIDER_ID, TOKENPLAN_MODEL_CATALOG), + wrapStreamFn: wrapTencentProviderStream, }); }, }); diff --git a/extensions/tencent/models.ts b/extensions/tencent/models.ts index c2625d4759c..b94b59dfd46 100644 --- a/extensions/tencent/models.ts +++ b/extensions/tencent/models.ts @@ -24,3 +24,25 @@ export function buildTokenHubModelDefinition( api: "openai-completions", }; } + +// ---------- TokenPlan provider ---------- + +export const TOKENPLAN_PROVIDER_ID = "tencent-tokenplan"; + +const TOKENPLAN_MANIFEST_PROVIDER = buildManifestModelProviderConfig({ + providerId: TOKENPLAN_PROVIDER_ID, + catalog: manifest.modelCatalog.providers[TOKENPLAN_PROVIDER_ID], +}); + +export const TOKENPLAN_BASE_URL = TOKENPLAN_MANIFEST_PROVIDER.baseUrl; + +export const TOKENPLAN_MODEL_CATALOG: ModelDefinitionConfig[] = TOKENPLAN_MANIFEST_PROVIDER.models; + +export function buildTokenPlanModelDefinition( + model: (typeof TOKENPLAN_MODEL_CATALOG)[number], +): ModelDefinitionConfig { + return { + ...model, + api: "openai-completions", + }; +} diff --git a/extensions/tencent/onboard.ts b/extensions/tencent/onboard.ts index cb18a52b552..650b91591ce 100644 --- a/extensions/tencent/onboard.ts +++ b/extensions/tencent/onboard.ts @@ -6,20 +6,29 @@ import { } from "openclaw/plugin-sdk/provider-onboard"; import { buildTokenHubModelDefinition, + buildTokenPlanModelDefinition, TOKENHUB_BASE_URL, TOKENHUB_MODEL_CATALOG, TOKENHUB_PROVIDER_ID, + TOKENPLAN_BASE_URL, + TOKENPLAN_MODEL_CATALOG, + TOKENPLAN_PROVIDER_ID, } from "./api.js"; // ---------- TokenHub ---------- -export const TOKENHUB_DEFAULT_MODEL_REF = `${TOKENHUB_PROVIDER_ID}/hy3-preview`; +export const TOKENHUB_DEFAULT_MODEL_REF = `${TOKENHUB_PROVIDER_ID}/hy3`; +export const TOKENHUB_PREVIEW_MODEL_REF = `${TOKENHUB_PROVIDER_ID}/hy3-preview`; function applyTokenHubProviderConfig(cfg: OpenClawConfig): OpenClawConfig { const models = { ...cfg.agents?.defaults?.models }; models[TOKENHUB_DEFAULT_MODEL_REF] = { ...models[TOKENHUB_DEFAULT_MODEL_REF], - alias: models[TOKENHUB_DEFAULT_MODEL_REF]?.alias ?? "Hy3 preview (TokenHub)", + alias: models[TOKENHUB_DEFAULT_MODEL_REF]?.alias ?? "Hy3 (TokenHub)", + }; + models[TOKENHUB_PREVIEW_MODEL_REF] = { + ...models[TOKENHUB_PREVIEW_MODEL_REF], + alias: models[TOKENHUB_PREVIEW_MODEL_REF]?.alias ?? "Hy3 preview (TokenHub)", }; return applyProviderConfigWithModelCatalog(cfg, { @@ -37,3 +46,30 @@ export function applyTokenHubConfig(cfg: OpenClawConfig): OpenClawConfig { TOKENHUB_DEFAULT_MODEL_REF, ); } + +// ---------- TokenPlan ---------- + +export const TOKENPLAN_DEFAULT_MODEL_REF = `${TOKENPLAN_PROVIDER_ID}/hy3`; + +function applyTokenPlanProviderConfig(cfg: OpenClawConfig): OpenClawConfig { + const models = { ...cfg.agents?.defaults?.models }; + models[TOKENPLAN_DEFAULT_MODEL_REF] = { + ...models[TOKENPLAN_DEFAULT_MODEL_REF], + alias: models[TOKENPLAN_DEFAULT_MODEL_REF]?.alias ?? "Hy3 (TokenPlan)", + }; + + return applyProviderConfigWithModelCatalog(cfg, { + agentModels: models, + providerId: TOKENPLAN_PROVIDER_ID, + api: "openai-completions", + baseUrl: TOKENPLAN_BASE_URL, + catalogModels: TOKENPLAN_MODEL_CATALOG.map(buildTokenPlanModelDefinition), + }); +} + +export function applyTokenPlanConfig(cfg: OpenClawConfig): OpenClawConfig { + return applyAgentDefaultModelPrimary( + applyTokenPlanProviderConfig(cfg), + TOKENPLAN_DEFAULT_MODEL_REF, + ); +} diff --git a/extensions/tencent/openclaw.plugin.json b/extensions/tencent/openclaw.plugin.json index 9bfcee54dd2..6f755e819dc 100644 --- a/extensions/tencent/openclaw.plugin.json +++ b/extensions/tencent/openclaw.plugin.json @@ -5,7 +5,7 @@ }, "enabledByDefault": true, "providerCatalogEntry": "./provider-discovery.ts", - "providers": ["tencent-tokenhub"], + "providers": ["tencent-tokenhub", "tencent-tokenplan"], "modelCatalog": { "providers": { "tencent-tokenhub": { @@ -50,14 +50,48 @@ }, "compat": { "supportsUsageInStreaming": true, - "supportsReasoningEffort": true + "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["none", "low", "high"] + } + }, + { + "id": "hy3", + "name": "Hy3 (TokenHub)", + "reasoning": true, + "input": ["text"], + "contextWindow": 256000, + "maxTokens": 64000, + "compat": { + "supportsUsageInStreaming": true, + "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["none", "high"] + } + } + ] + }, + "tencent-tokenplan": { + "baseUrl": "https://api.lkeap.cloud.tencent.com/plan/v3", + "api": "openai-completions", + "models": [ + { + "id": "hy3", + "name": "Hy3 (TokenPlan)", + "reasoning": true, + "input": ["text"], + "contextWindow": 256000, + "maxTokens": 64000, + "compat": { + "supportsUsageInStreaming": true, + "supportsReasoningEffort": true, + "supportedReasoningEfforts": ["none", "high"] } } ] } }, "discovery": { - "tencent-tokenhub": "static" + "tencent-tokenhub": "static", + "tencent-tokenplan": "static" } }, "setup": { @@ -65,9 +99,20 @@ { "id": "tencent-tokenhub", "envVars": ["TOKENHUB_API_KEY"] + }, + { + "id": "tencent-tokenplan", + "envVars": ["TOKENPLAN_API_KEY"] } ] }, + "configContracts": { + "compatibilityMigrationPaths": [ + "models.providers.tencent-tokenhub", + "agents.defaults.models.tencent-tokenhub/hy3", + "agents.defaults.models.tencent-tokenhub/hy3-preview" + ] + }, "providerAuthChoices": [ { "provider": "tencent-tokenhub", @@ -81,6 +126,19 @@ "cliFlag": "--tokenhub-api-key", "cliOption": "--tokenhub-api-key ", "cliDescription": "Tencent TokenHub API key" + }, + { + "provider": "tencent-tokenplan", + "method": "api-key", + "choiceId": "tokenplan-api-key", + "choiceLabel": "Tencent TokenPlan", + "groupId": "tencent", + "groupLabel": "Tencent Cloud", + "groupHint": "Tencent TokenPlan", + "optionKey": "tokenplanApiKey", + "cliFlag": "--tokenplan-api-key", + "cliOption": "--tokenplan-api-key ", + "cliDescription": "Tencent TokenPlan API key" } ], "configSchema": { diff --git a/extensions/tencent/package.json b/extensions/tencent/package.json index 1d71b6efb1c..d2779d38ec5 100644 --- a/extensions/tencent/package.json +++ b/extensions/tencent/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-api.ts", "install": { "clawhubSpec": "clawhub:@openclaw/tencent-provider", "npmSpec": "@openclaw/tencent-provider", diff --git a/extensions/tencent/provider-catalog.ts b/extensions/tencent/provider-catalog.ts index 56757de0f73..afc1a02baa0 100644 --- a/extensions/tencent/provider-catalog.ts +++ b/extensions/tencent/provider-catalog.ts @@ -2,8 +2,11 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { buildTokenHubModelDefinition, + buildTokenPlanModelDefinition, TOKENHUB_BASE_URL, TOKENHUB_MODEL_CATALOG, + TOKENPLAN_BASE_URL, + TOKENPLAN_MODEL_CATALOG, } from "./models.js"; export function buildTokenHubProvider(): ModelProviderConfig { @@ -13,3 +16,11 @@ export function buildTokenHubProvider(): ModelProviderConfig { models: TOKENHUB_MODEL_CATALOG.map(buildTokenHubModelDefinition), }; } + +export function buildTokenPlanProvider(): ModelProviderConfig { + return { + baseUrl: TOKENPLAN_BASE_URL, + api: "openai-completions", + models: TOKENPLAN_MODEL_CATALOG.map(buildTokenPlanModelDefinition), + }; +} diff --git a/extensions/tencent/provider-discovery.ts b/extensions/tencent/provider-discovery.ts index 66ca4f4e580..ea38655f0bf 100644 --- a/extensions/tencent/provider-discovery.ts +++ b/extensions/tencent/provider-discovery.ts @@ -1,18 +1,32 @@ // Tencent provider module implements model/runtime integration. import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; -import { buildTokenHubProvider } from "./provider-catalog.js"; +import { buildTokenHubProvider, buildTokenPlanProvider } from "./provider-catalog.js"; -const tencentProviderDiscovery: ProviderPlugin = { - id: "tencent-tokenhub", - label: "Tencent TokenHub", - docsPath: "/providers/models", - auth: [], - staticCatalog: { - order: "simple", - run: async () => ({ - provider: buildTokenHubProvider(), - }), +const tencentProviderDiscovery: ProviderPlugin[] = [ + { + id: "tencent-tokenhub", + label: "Tencent TokenHub", + docsPath: "/providers/tencent", + auth: [], + staticCatalog: { + order: "simple", + run: async () => ({ + provider: buildTokenHubProvider(), + }), + }, }, -}; + { + id: "tencent-tokenplan", + label: "Tencent TokenPlan", + docsPath: "/providers/tencent", + auth: [], + staticCatalog: { + order: "simple", + run: async () => ({ + provider: buildTokenPlanProvider(), + }), + }, + }, +]; export default tencentProviderDiscovery; diff --git a/extensions/tencent/setup-api.ts b/extensions/tencent/setup-api.ts new file mode 100644 index 00000000000..092670947da --- /dev/null +++ b/extensions/tencent/setup-api.ts @@ -0,0 +1,12 @@ +// Tencent setup entrypoint exposes lightweight config repair hooks. +import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; +import { migrateTencentTokenHubModelDefaults } from "./config-compat.js"; + +export default definePluginEntry({ + id: "tencent", + name: "Tencent Cloud Provider Setup", + description: "Lightweight Tencent Cloud provider setup hooks", + register(api) { + api.registerConfigMigration((config) => migrateTencentTokenHubModelDefaults(config)); + }, +}); diff --git a/extensions/tencent/stream.ts b/extensions/tencent/stream.ts new file mode 100644 index 00000000000..62077fdeea6 --- /dev/null +++ b/extensions/tencent/stream.ts @@ -0,0 +1,92 @@ +import type { StreamFn } from "openclaw/plugin-sdk/agent-core"; +import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; +import { + createPayloadPatchStreamWrapper, + type OpenAICompatibleThinkingLevel, +} from "openclaw/plugin-sdk/provider-stream-shared"; +import { TOKENHUB_PROVIDER_ID, TOKENPLAN_PROVIDER_ID } from "./models.js"; + +const TENCENT_PROVIDER_IDS: ReadonlySet = new Set([ + TOKENHUB_PROVIDER_ID, + TOKENPLAN_PROVIDER_ID, +]); + +type StreamModel = Parameters[0]; +type StreamOptions = Parameters[2]; + +const TENCENT_REASONING_EFFORT_MAP: Readonly> = Object.freeze({ + off: "none", + none: "none", + minimal: "high", + low: "high", + medium: "high", + high: "high", + xhigh: "high", +}); + +const TOKENHUB_HY3_PREVIEW_REASONING_EFFORTS = new Set(["none", "low", "high"]); + +function resolveRequestedEffort( + thinkingLevel: OpenAICompatibleThinkingLevel, + options: StreamOptions, +): string | undefined { + const withEffort = (options ?? {}) as { reasoningEffort?: unknown; reasoning?: unknown }; + const raw = + (typeof withEffort.reasoningEffort === "string" && withEffort.reasoningEffort) || + (typeof withEffort.reasoning === "string" && withEffort.reasoning) || + (typeof thinkingLevel === "string" && thinkingLevel) || + undefined; + return raw ? raw.trim().toLowerCase() : undefined; +} + +function mapEffortForTencent(model: StreamModel, effort: string | undefined): string | undefined { + if (!effort) { + return undefined; + } + if ( + (model as { provider?: unknown }).provider === TOKENHUB_PROVIDER_ID && + (model as { id?: unknown }).id === "hy3-preview" + ) { + if (effort === "off") { + return "none"; + } + // Preview supports low directly. Leave unsupported requests to the shared + // model fallback that already normalized the underlying payload. + return TOKENHUB_HY3_PREVIEW_REASONING_EFFORTS.has(effort) ? effort : undefined; + } + return TENCENT_REASONING_EFFORT_MAP[effort]; +} + +function isTencentCompletionsCall(model: StreamModel): boolean { + const provider = (model as { provider?: unknown }).provider; + const api = (model as { api?: unknown }).api; + return ( + typeof provider === "string" && + TENCENT_PROVIDER_IDS.has(provider) && + api === "openai-completions" + ); +} + +export function wrapTencentProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn { + return createPayloadPatchStreamWrapper( + ctx.streamFn, + ({ payload, model, options }) => { + const requested = resolveRequestedEffort(ctx.thinkingLevel, options); + const mapped = mapEffortForTencent(model, requested); + + if (mapped === undefined) { + return; + } + + if (mapped === "none" || mapped === "off") { + payload.reasoning_effort = "none"; + return; + } + + payload.reasoning_effort = mapped; + }, + { + shouldPatch: ({ model }) => isTencentCompletionsCall(model), + }, + ); +} diff --git a/scripts/lib/official-external-provider-catalog.json b/scripts/lib/official-external-provider-catalog.json index 8f0fb94e0de..db5dfacc879 100644 --- a/scripts/lib/official-external-provider-catalog.json +++ b/scripts/lib/official-external-provider-catalog.json @@ -1221,6 +1221,36 @@ ] } ] + }, + { + "id": "tencent-tokenplan", + "name": "Tencent TokenPlan", + "docs": "/providers/tencent", + "categories": [ + "cloud", + "llm" + ], + "envVars": [ + "TOKENPLAN_API_KEY" + ], + "authChoices": [ + { + "method": "api-key", + "choiceId": "tokenplan-api-key", + "choiceLabel": "Tencent TokenPlan", + "choiceHint": "Hy via Tencent TokenPlan Gateway", + "groupId": "tencent", + "groupLabel": "Tencent Cloud", + "groupHint": "Tencent TokenPlan", + "optionKey": "tokenplanApiKey", + "cliFlag": "--tokenplan-api-key", + "cliOption": "--tokenplan-api-key ", + "cliDescription": "Tencent TokenPlan API key", + "onboardingScopes": [ + "text-inference" + ] + } + ] } ], "install": { diff --git a/src/config/config-misc.test.ts b/src/config/config-misc.test.ts index 6c2d1021ca1..9c117dbe1e8 100644 --- a/src/config/config-misc.test.ts +++ b/src/config/config-misc.test.ts @@ -77,11 +77,14 @@ describe("model provider localService config", () => { } }); - it("accepts standalone timeout overlays for Xiaomi Token Plan", () => { + it.each([ + { provider: "xiaomi-token-plan", name: "Xiaomi Token Plan" }, + { provider: "tencent-tokenplan", name: "Tencent TokenPlan" }, + ] as const)("accepts standalone timeout overlays for $name", ({ provider }) => { const result = validateConfigObjectRaw({ models: { providers: { - "xiaomi-token-plan": { + [provider]: { timeoutSeconds: 600, }, }, @@ -90,9 +93,9 @@ describe("model provider localService config", () => { expect(result.ok).toBe(true); if (result.ok) { - expect(result.config.models?.providers?.["xiaomi-token-plan"]?.timeoutSeconds).toBe(600); - expect(result.config.models?.providers?.["xiaomi-token-plan"]?.models).toEqual([]); - expect(result.config.models?.providers?.["xiaomi-token-plan"]?.baseUrl).toBe(""); + expect(result.config.models?.providers?.[provider]?.timeoutSeconds).toBe(600); + expect(result.config.models?.providers?.[provider]?.models).toEqual([]); + expect(result.config.models?.providers?.[provider]?.baseUrl).toBe(""); } }); diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 0735a4fc0e2..b27a8e45812 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -497,6 +497,7 @@ const BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS = new Set([ "stepfun-plan", "synthetic", "tencent-tokenhub", + "tencent-tokenplan", "together", "venice", "vercel-ai-gateway", diff --git a/src/infra/dotenv.ts b/src/infra/dotenv.ts index 86b8b202874..6492c6df00e 100644 --- a/src/infra/dotenv.ts +++ b/src/infra/dotenv.ts @@ -80,6 +80,7 @@ const BLOCKED_PROVIDER_AUTH_WORKSPACE_DOTENV_KEYS = [ "TAVILY_API_KEY", "TOGETHER_API_KEY", "TOKENHUB_API_KEY", + "TOKENPLAN_API_KEY", "VENICE_API_KEY", "VLLM_API_KEY", "VOLCANO_ENGINE_API_KEY", diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index 22f44cbf97f..3ffd7856823 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -1823,6 +1823,7 @@ describe("official external plugin catalog", () => { STEPFUN_API_KEY: "stepfun-key", FIREWORKS_API_KEY: "fireworks-key", TOKENHUB_API_KEY: "tokenhub-key", + TOKENPLAN_API_KEY: "tokenplan-key", VENICE_API_KEY: "venice-key", AI_GATEWAY_API_KEY: "gateway-key", ZAI_API_KEY: "zai-key", @@ -1850,6 +1851,31 @@ describe("official external plugin catalog", () => { expect(resolveOfficialExternalProviderPluginIdsForEnv({ GROQ_API_KEY: " " })).toEqual([]); }); + it("keeps Tencent auth choices available through the cold-install auth catalog", () => { + const tencent = expectCatalogEntry("tencent"); + const tokenHub = tencent.openclaw?.providers?.find( + (provider) => provider.id === "tencent-tokenhub", + ); + const tokenPlan = tencent.openclaw?.providers?.find( + (provider) => provider.id === "tencent-tokenplan", + ); + + expect(tokenHub?.envVars).toEqual(["TOKENHUB_API_KEY"]); + expect(tokenHub?.authChoices).toEqual([ + expect.objectContaining({ + choiceId: "tokenhub-api-key", + optionKey: "tokenhubApiKey", + cliFlag: "--tokenhub-api-key", + }), + ]); + expect(tokenPlan?.envVars).toEqual(["TOKENPLAN_API_KEY"]); + expect(tokenPlan?.authChoices?.[0]).toMatchObject({ + choiceId: "tokenplan-api-key", + optionKey: "tokenplanApiKey", + cliFlag: "--tokenplan-api-key", + }); + }); + it("keeps Groq available through the cold-install auth catalog", () => { const groq = expectCatalogEntry("groq"); const authChoice = groq.openclaw?.providers?.find((provider) => provider.id === "groq") diff --git a/src/plugins/setup-registry.tencent.test.ts b/src/plugins/setup-registry.tencent.test.ts new file mode 100644 index 00000000000..86562c629a5 --- /dev/null +++ b/src/plugins/setup-registry.tencent.test.ts @@ -0,0 +1,38 @@ +// Covers Tencent setup config migration registration in the plugin setup registry. +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { runPluginSetupConfigMigrations } from "./setup-registry.js"; + +describe("Tencent setup config migration", () => { + test("repairs TokenHub model defaults through setup registry", () => { + const result = runPluginSetupConfigMigrations({ + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: path.resolve("extensions"), + }, + config: { + agents: { + defaults: { + model: { primary: "tencent-tokenhub/hy3-preview" }, + models: { + "tencent-tokenhub/hy3-preview": {}, + }, + }, + }, + } as OpenClawConfig, + }); + + expect(result.changes).toEqual([ + "Updated Tencent TokenHub agent model defaults to include tencent-tokenhub/hy3 and tencent-tokenhub/hy3-preview.", + "Changed Tencent TokenHub primary default from tencent-tokenhub/hy3-preview to tencent-tokenhub/hy3.", + ]); + expect(result.config.agents?.defaults?.model).toEqual({ + primary: "tencent-tokenhub/hy3", + }); + expect(Object.keys(result.config.agents?.defaults?.models ?? {}).toSorted()).toEqual([ + "tencent-tokenhub/hy3", + "tencent-tokenhub/hy3-preview", + ]); + }); +}); diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts index 9a118f63a38..e46f0b22425 100644 --- a/test/plugin-npm-runtime-build.test.ts +++ b/test/plugin-npm-runtime-build.test.ts @@ -105,4 +105,19 @@ describe("plugin npm runtime build planning", () => { expect(plan.packageFiles).toContain("dist/**"); } }); + + it("builds Tencent setup metadata for installed-package migrations", () => { + const plan = expectPluginNpmRuntimeBuildPlan( + resolvePluginNpmRuntimeBuildPlan({ + repoRoot, + packageDir: path.join(repoRoot, "extensions", "tencent"), + }), + ); + + expect(plan.entry["setup-api"]).toBe( + path.join(repoRoot, "extensions", "tencent", "setup-api.ts"), + ); + expect(plan.runtimeSetupEntry).toBe("./dist/setup-api.js"); + expect(plan.runtimeBuildOutputs).toContain("./dist/setup-api.js"); + }); });