From 9d1c5a77c281a78a4fabd569959ee0997ff2c4c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 12:13:20 +0100 Subject: [PATCH] fix: add vercel ai gateway thinking profile Adds a Vercel AI Gateway provider thinking-profile resolver for trusted OpenAI and Anthropic upstream refs, preserving catalog compat fallback for unsupported/base-only refs. Includes provider tests, docs, and changelog coverage. Supersedes #41561. Co-authored-by: Zcg2021 <80769518+Zcg2021@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/providers/vercel-ai-gateway.md | 9 +++ extensions/vercel-ai-gateway/index.ts | 2 + extensions/vercel-ai-gateway/thinking.test.ts | 72 +++++++++++++++++ extensions/vercel-ai-gateway/thinking.ts | 77 +++++++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 extensions/vercel-ai-gateway/thinking.test.ts create mode 100644 extensions/vercel-ai-gateway/thinking.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 224c18bfa80..e6dba75f6a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ Docs: https://docs.openclaw.ai - Cron/Gateway: defer missed isolated agent-turn catch-up out of the channel startup window, so overdue cron work cannot starve Discord or Telegram while providers connect after a restart. Thanks @vincentkoc. - Heartbeat/cron: defer heartbeat turns while cron work is active or queued, add opt-in `heartbeat.skipWhenBusy` for subagent/nested lane pressure, and retry busy skips without advancing the schedule so local Ollama hosts do not run heartbeat and cron prompts concurrently. Fixes #50773. Thanks @scottgl9. - Agents/thinking: honor configured model `compat.supportedReasoningEfforts` entries that include `xhigh`, so custom OpenAI-compatible provider refs expose and validate `/think xhigh` consistently across command menus, Gateway sessions, agent CLI, and `llm-task`. Carries forward #48904. Thanks @Milchstrassse and @wufunc. +- Vercel AI Gateway: expose provider-owned `/think xhigh` for trusted OpenAI/Codex upstream refs and Claude adaptive thinking for Anthropic upstream refs, while leaving untrusted namespaced refs on base levels. Carries forward #41561. Thanks @Zcg2021. - Plugins/runtime-deps: prune stale `openclaw-unknown-*` bundled runtime dependency roots during Gateway startup while keeping recent or locked roots, so old staging debris cannot keep growing across restarts. Thanks @vincentkoc. - Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus. - Doctor/TTS: migrate legacy `messages.tts.enabled`, agent TTS, channel TTS, and voice-call plugin TTS toggles to `auto` mode during `openclaw doctor --fix`, matching the documented TTS config contract. Thanks @vincentkoc. diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md index 1865702a141..c0df6c9a9fa 100644 --- a/docs/providers/vercel-ai-gateway.md +++ b/docs/providers/vercel-ai-gateway.md @@ -105,6 +105,15 @@ configuration. OpenClaw resolves the canonical form automatically. MoonshotAI. Your single `AI_GATEWAY_API_KEY` handles authentication for all upstream providers. + + `/think` options follow trusted upstream model prefixes when OpenClaw knows + the upstream provider contract. `vercel-ai-gateway/anthropic/...` uses the + Claude thinking profile, including adaptive defaults for Claude 4.6 models. + `vercel-ai-gateway/openai/gpt-5.4`, `gpt-5.5`, and Codex-style refs expose + `/think xhigh` just like the direct OpenAI/OpenAI Codex providers. Other + namespaced refs keep the normal reasoning levels unless their catalog + metadata declares more. + ## Related diff --git a/extensions/vercel-ai-gateway/index.ts b/extensions/vercel-ai-gateway/index.ts index fdbba1740be..7a0d8db13bc 100644 --- a/extensions/vercel-ai-gateway/index.ts +++ b/extensions/vercel-ai-gateway/index.ts @@ -4,6 +4,7 @@ import { buildStaticVercelAiGatewayProvider, buildVercelAiGatewayProvider, } from "./provider-catalog.js"; +import { resolveVercelAiGatewayThinkingProfile } from "./thinking.js"; const PROVIDER_ID = "vercel-ai-gateway"; @@ -35,5 +36,6 @@ export default defineSingleProviderPluginEntry({ buildProvider: buildVercelAiGatewayProvider, buildStaticProvider: buildStaticVercelAiGatewayProvider, }, + resolveThinkingProfile: ({ modelId }) => resolveVercelAiGatewayThinkingProfile(modelId), }, }); diff --git a/extensions/vercel-ai-gateway/thinking.test.ts b/extensions/vercel-ai-gateway/thinking.test.ts new file mode 100644 index 00000000000..29a6ec5a9a5 --- /dev/null +++ b/extensions/vercel-ai-gateway/thinking.test.ts @@ -0,0 +1,72 @@ +import { + registerProviderPlugin, + requireRegisteredProvider, +} from "openclaw/plugin-sdk/plugin-test-runtime"; +import { describe, expect, it } from "vitest"; +import plugin from "./index.js"; + +describe("vercel ai gateway thinking profile", () => { + async function getProvider() { + const { providers } = await registerProviderPlugin({ + plugin, + id: "vercel-ai-gateway", + name: "Vercel AI Gateway Provider", + }); + return requireRegisteredProvider(providers, "vercel-ai-gateway"); + } + + it("exposes xhigh for trusted OpenAI upstream refs", async () => { + const provider = await getProvider(); + + const profile = provider.resolveThinkingProfile?.({ + provider: "vercel-ai-gateway", + modelId: "openai/gpt-5.4", + }); + + expect(profile?.levels).toEqual(expect.arrayContaining([{ id: "xhigh" }])); + }); + + it("exposes Codex xhigh through the OpenAI upstream prefix", async () => { + const provider = await getProvider(); + + const profile = provider.resolveThinkingProfile?.({ + provider: "vercel-ai-gateway", + modelId: "openai/gpt-5.3-codex-spark", + }); + + expect(profile?.levels).toEqual(expect.arrayContaining([{ id: "xhigh" }])); + }); + + it("reuses Claude thinking defaults for trusted Anthropic upstream refs", async () => { + const provider = await getProvider(); + + const profile = provider.resolveThinkingProfile?.({ + provider: "vercel-ai-gateway", + modelId: "anthropic/claude-opus-4.6", + }); + + expect(profile).toMatchObject({ + levels: expect.arrayContaining([{ id: "adaptive" }]), + defaultLevel: "adaptive", + }); + expect(profile?.levels.some((level) => level.id === "xhigh" || level.id === "max")).toBe(false); + }); + + it("falls through for unsupported OpenAI or untrusted namespaced refs", async () => { + const provider = await getProvider(); + const resolveThinkingProfile = provider.resolveThinkingProfile!; + + expect( + resolveThinkingProfile({ + provider: "vercel-ai-gateway", + modelId: "openai/gpt-4.1", + }), + ).toBeUndefined(); + expect( + resolveThinkingProfile({ + provider: "vercel-ai-gateway", + modelId: "acme/gpt-5.4", + }), + ).toBeUndefined(); + }); +}); diff --git a/extensions/vercel-ai-gateway/thinking.ts b/extensions/vercel-ai-gateway/thinking.ts new file mode 100644 index 00000000000..2dffc0333f2 --- /dev/null +++ b/extensions/vercel-ai-gateway/thinking.ts @@ -0,0 +1,77 @@ +import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/core"; +import { + matchesExactOrPrefix, + resolveClaudeThinkingProfile, +} from "openclaw/plugin-sdk/provider-model-shared"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; + +const UPSTREAM_OPENAI_PREFIX = "openai/"; +const UPSTREAM_ANTHROPIC_PREFIX = "anthropic/"; + +const BASE_OPENAI_THINKING_LEVELS = [ + { id: "off" }, + { id: "minimal" }, + { id: "low" }, + { id: "medium" }, + { id: "high" }, +] as const satisfies ProviderThinkingProfile["levels"]; + +const VERCEL_OPENAI_XHIGH_MODEL_IDS = [ + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.4", + "gpt-5.4-pro", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.3-codex", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.1-codex", +] as const; + +function stripTrustedUpstreamPrefix(modelId: string, prefix: string): string | null { + const normalized = normalizeLowercaseStringOrEmpty(modelId); + if (!normalized.startsWith(prefix)) { + return null; + } + const upstreamModelId = normalized.slice(prefix.length).trim(); + return upstreamModelId || null; +} + +function resolveOpenAiThinkingProfile(modelId: string): ProviderThinkingProfile | undefined { + if (!matchesExactOrPrefix(modelId, VERCEL_OPENAI_XHIGH_MODEL_IDS)) { + return undefined; + } + return { + levels: [...BASE_OPENAI_THINKING_LEVELS, { id: "xhigh" }], + }; +} + +function hasVercelSpecificClaudeProfile(profile: ProviderThinkingProfile): boolean { + return Boolean( + profile.defaultLevel || + profile.levels.some( + (level) => level.id === "adaptive" || level.id === "xhigh" || level.id === "max", + ), + ); +} + +export function resolveVercelAiGatewayThinkingProfile( + modelId: string, +): ProviderThinkingProfile | undefined { + const openAiModelId = stripTrustedUpstreamPrefix(modelId, UPSTREAM_OPENAI_PREFIX); + if (openAiModelId) { + return resolveOpenAiThinkingProfile(openAiModelId); + } + + const anthropicModelId = stripTrustedUpstreamPrefix(modelId, UPSTREAM_ANTHROPIC_PREFIX); + if (anthropicModelId) { + const profile = resolveClaudeThinkingProfile(anthropicModelId); + // Returning a base-only provider profile would hide catalog compat metadata + // from generic thinking resolution. Only take over when Claude has an + // upstream-specific default or elevated level set. + return hasVercelSpecificClaudeProfile(profile) ? profile : undefined; + } + + return undefined; +}