Route GLM thinking via provider metadata ENG-2019 (#10692)

* Route GLM thinking via provider metadata

* Address GLM provider routing review feedback
This commit is contained in:
Robin Newhouse
2026-05-19 12:12:51 -07:00
committed by GitHub
parent 6e964c3ef0
commit ed008bd36e
10 changed files with 139 additions and 44 deletions
+5
View File
@@ -15,6 +15,11 @@ alwaysApply: true
quirks, default behavior, or wire-format details.
- Stable, reliable known-model facts belong in typed `ModelInfo.metadata`
helpers or `src/providers/model-facts.ts`.
- Stable provider routing facts belong in `GatewayProviderMetadata.routing`
and the relevant builtin provider manifest. For example, if a native
provider uses a known reasoning wire format for a model route, add a typed
`GatewayReasoningFormat` value and route metadata instead of matching that
provider id directly in a rule predicate.
- Provider wire-format encoding belongs in `PROVIDER_OPTION_RULES` and codec
helpers under `src/providers/routing`.
- Local or dynamic provider fallbacks, such as Ollama or routed model-id
+2 -1
View File
@@ -2,4 +2,5 @@
## Next Release
- Supports Bedrock bearer API keys, direct IAM credentials, AWS profiles, and the default AWS SDK credential chain
- Supports Bedrock bearer API keys, direct IAM credentials, AWS profiles, and the default AWS SDK credential chain
- Routes Z.AI GLM thinking through provider metadata while preserving generic thinking suppression for non-GLM Z.AI custom models
@@ -103,4 +103,24 @@ describe("built-in provider metadata", () => {
}),
);
});
it("routes native Z.AI providers through GLM thinking metadata", async () => {
for (const providerId of ["zai", "zai-coding-plan"] as const) {
await expect(getProvider(providerId)).resolves.toMatchObject({
metadata: {
routing: {
reasoning: {
format: "glm-thinking",
},
},
},
});
const models = Object.values(await getModelsForProvider(providerId));
expect(models.length).toBeGreaterThan(0);
for (const model of models) {
expect(model.family?.startsWith("glm")).toBe(true);
}
}
});
});
@@ -21,6 +21,7 @@ import {
ANTHROPIC_ROUTING_METADATA,
QWEN_CACHE_ROUTING_METADATA,
} from "./routing/anthropic-compatible";
import { GLM_THINKING_ROUTING_METADATA } from "./routing/glm-thinking";
export const DEFAULT_INTERNAL_OCA_BASE_URL =
"https://code-internal.aiservice.us-chicago-1.oci.oraclecloud.com/20250206/app/litellm";
@@ -507,6 +508,7 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
apiKeyEnv: ["ZHIPU_API_KEY"],
modelsProviderId: "zai",
defaults: { baseUrl: "https://api.z.ai/api/paas/v4" },
metadata: GLM_THINKING_ROUTING_METADATA,
},
{
id: "zai-coding-plan",
@@ -518,6 +520,7 @@ const OPENAI_COMPATIBLE_SPECS: BuiltinSpec[] = [
apiKeyEnv: ["ZHIPU_API_KEY"],
modelsProviderId: "zai-coding-plan",
defaults: { baseUrl: "https://api.z.ai/api/coding/paas/v4" },
metadata: GLM_THINKING_ROUTING_METADATA,
},
{
id: "moonshot",
@@ -2712,7 +2712,7 @@ describe("sdk-gateway", () => {
);
});
it("does not apply Z.AI GLM thinking controls to non-GLM native Z.AI models", async () => {
it("does not apply generic thinking to non-GLM native Z.AI custom models", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([
{ type: "finish", usage: { inputTokens: 1, outputTokens: 1 } },
@@ -1,5 +1,6 @@
import type {
GatewayModelRoute,
GatewayReasoningFormat,
GatewayProviderContext,
GatewayStreamRequest,
} from "@cline/shared";
@@ -128,6 +129,25 @@ export function modelRouteMatches(
}
}
export function providerReasoningRouteMatches(
format: GatewayReasoningFormat,
request: Pick<GatewayStreamRequest, "modelId">,
context: GatewayProviderContext,
): boolean {
const reasoning = context.provider.metadata?.routing?.reasoning;
if (reasoning?.format !== format) {
return false;
}
return reasoning.routes.some((route) =>
modelRouteMatches(route, {
modelId: request.modelId,
family: resolveModelFamily(context),
capabilities: context.model.capabilities,
}),
);
}
export function isGlmModel(
request: Pick<GatewayStreamRequest, "modelId">,
context: GatewayProviderContext,
@@ -1,5 +1,6 @@
import type {
GatewayProviderContext,
GatewayProviderMetadata,
GatewayStreamRequest,
} from "@cline/shared";
import { isGlmModel } from "../model-facts";
@@ -14,9 +15,18 @@ import type { ProviderOptionsPatch } from "./utils";
* composer can rely on merge order instead of out-of-band flags.
*/
export function isNativeZaiProvider(providerId: string): boolean {
return providerId === "zai" || providerId === "zai-coding-plan";
}
export const GLM_THINKING_ROUTING_METADATA: GatewayProviderMetadata = {
routing: {
reasoning: {
format: "glm-thinking",
routes: [
{ matcher: "model-family", family: "glm" },
{ matcher: "model-family", family: "glm-air" },
{ matcher: "model-family", family: "glm-flash" },
],
},
},
};
function buildNativeZaiThinkingOptions(request: GatewayStreamRequest) {
if (request.reasoning?.enabled === undefined) {
@@ -47,28 +57,32 @@ function buildRoutedGlmReasoningOptions(request: GatewayStreamRequest) {
return undefined;
}
export function buildGlmThinkingProviderOptionsPatch(
export function buildNativeGlmThinkingProviderOptionsPatch(
request: GatewayStreamRequest,
providerOptionsKey: string,
): ProviderOptionsPatch | undefined {
// Native Z.AI GLM endpoints expect `thinking.type`; they do not accept the
// routed `reasoning.enabled` / `reasoning.exclude` shape.
const nativeThinking = buildNativeZaiThinkingOptions(request);
return nativeThinking
? {
openaiCompatible: nativeThinking,
[request.providerId]: nativeThinking,
...(providerOptionsKey !== request.providerId
? { [providerOptionsKey]: nativeThinking }
: {}),
}
: undefined;
}
export function buildRoutedGlmReasoningProviderOptionsPatch(
request: GatewayStreamRequest,
context: GatewayProviderContext,
providerOptionsKey: string,
options?: { includeProviderBuckets?: boolean },
): ProviderOptionsPatch | undefined {
if (isNativeZaiProvider(request.providerId)) {
if (!isGlmModel(request, context)) {
return undefined;
}
const nativeThinking = buildNativeZaiThinkingOptions(request);
return nativeThinking
? {
openaiCompatible: nativeThinking,
[request.providerId]: nativeThinking,
...(providerOptionsKey !== request.providerId
? { [providerOptionsKey]: nativeThinking }
: {}),
}
: undefined;
}
// Routed GLM endpoints stay OpenAI-compatible and use the generic
// `reasoning` include/exclude shape instead of native Z.AI `thinking.type`.
if (!isGlmModel(request, context)) {
return undefined;
}
@@ -1,16 +1,17 @@
import { buildGatewayReasoningOptions } from "./anthropic-compatible";
import { buildOpenAINativeProviderOptions } from "./generic-compatible";
import {
buildGlmThinkingProviderOptionsPatch,
isNativeZaiProvider,
} from "./glm-thinking";
import {
isDeepSeekFamily,
isGlmModel,
isKimiK26Family as isKimiK26FamilyFact,
isMoonshotKimiModelIdFallback,
modelReasoningDefaultsOn,
providerReasoningRouteMatches,
} from "../model-facts";
import { buildGatewayReasoningOptions } from "./anthropic-compatible";
import { buildOpenAINativeProviderOptions } from "./generic-compatible";
import {
buildNativeGlmThinkingProviderOptionsPatch,
buildRoutedGlmReasoningProviderOptionsPatch,
} from "./glm-thinking";
import type {
MatchedProviderOptionRule,
ProviderOptionBuildInput,
@@ -54,6 +55,25 @@ function isOllamaReasoningDefaultOnDisable(
);
}
function usesGlmThinkingProviderRouting(
input: ProviderOptionMatchInput,
): boolean {
return providerReasoningRouteMatches(
"glm-thinking",
input.request,
input.context,
);
}
function hasGlmThinkingProviderRouting(
input: ProviderOptionMatchInput,
): boolean {
return (
input.context.provider.metadata?.routing?.reasoning?.format ===
"glm-thinking"
);
}
function resolveFamilyThinkingType(
input: ProviderOptionMatchInput,
defaultWhenUnset: "enabled" | "disabled" | undefined,
@@ -285,31 +305,28 @@ const ollamaReasoningDefaultOnDisableRule: ProviderOptionRule = {
},
};
const nativeZaiNonGlmSuppressionRule: ProviderOptionRule = {
id: "provider.zai.non-glm.suppress-generic-thinking",
const nonGlmProviderRoutingSuppressionRule: ProviderOptionRule = {
id: "provider.routing.glm-thinking.non-glm.suppress-generic-thinking",
phase: "provider",
description:
"Native Z.AI non-GLM models should not inherit adaptive OpenAI-compatible thinking.",
"Providers with GLM thinking routing should not apply generic adaptive thinking to non-GLM models.",
applies: (input) =>
isNativeZaiProvider(input.request.providerId) &&
hasGlmThinkingProviderRouting(input) &&
input.request.reasoning?.enabled !== undefined &&
!isGlmModel(input.request, input.context),
!usesGlmThinkingProviderRouting(input),
suppresses: { genericThinking: true },
build: () => undefined,
};
const nativeZaiGlmThinkingRule: ProviderOptionRule = {
id: "family.glm.native-zai-thinking",
id: "provider.routing.glm-thinking",
phase: "model-overlay",
description: "Native Z.AI GLM models use thinking.type.",
applies: (input) =>
isNativeZaiProvider(input.request.providerId) &&
isGlmModel(input.request, input.context),
description: "Providers routed to the GLM thinking format use thinking.type.",
applies: usesGlmThinkingProviderRouting,
suppresses: { genericThinking: true },
build: (input) =>
buildGlmThinkingProviderOptionsPatch(
buildNativeGlmThinkingProviderOptionsPatch(
input.request,
input.context,
input.providerOptionsKey,
),
};
@@ -320,11 +337,11 @@ const routedGlmReasoningRule: ProviderOptionRule = {
description:
"Routed GLM models use the generic reasoning include/exclude shape, not thinking.type.",
applies: (input) =>
!isNativeZaiProvider(input.request.providerId) &&
!usesGlmThinkingProviderRouting(input) &&
isGlmModel(input.request, input.context),
suppresses: { genericThinking: true },
build: (input) =>
buildGlmThinkingProviderOptionsPatch(
buildRoutedGlmReasoningProviderOptionsPatch(
input.request,
input.context,
input.providerOptionsKey,
@@ -353,7 +370,7 @@ export const PROVIDER_OPTION_RULES: ReadonlyArray<ProviderOptionRule> = [
kimiK26ThinkingRule,
deepSeekThinkingRule,
ollamaReasoningDefaultOnDisableRule,
nativeZaiNonGlmSuppressionRule,
nonGlmProviderRoutingSuppressionRule,
nativeZaiGlmThinkingRule,
routedGlmReasoningRule,
];
@@ -3,6 +3,7 @@ import type {
GatewayStreamRequest,
} from "@cline/shared";
import { describe, expect, it } from "vitest";
import { GLM_THINKING_ROUTING_METADATA } from "./glm-thinking";
import {
composeAiSdkProviderOptions,
mergeProviderOptionPatches,
@@ -770,6 +771,7 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
modelId: "glm-4.7",
reasoning: { enabled: true },
},
context: { family: "glm", metadata: GLM_THINKING_ROUTING_METADATA },
expect: [
{ bucket: "zai", has: { thinking: { type: "enabled" } } },
{
@@ -779,6 +781,19 @@ describe("composeAiSdkProviderOptions: family/provider thinking patches", () =>
},
],
},
{
name: "native zai custom non-GLM -> no generic adaptive thinking",
request: {
providerId: "zai",
modelId: "zai-other-model",
reasoning: { enabled: true },
},
context: { family: "other", metadata: GLM_THINKING_ROUTING_METADATA },
expect: [
{ bucket: "zai", lacks: ["thinking", "reasoning"] },
{ bucket: "openaiCompatible", lacks: ["thinking", "reasoning"] },
],
},
// Kimi K2.6 family: explicit enabled/disabled and unset defaults to enabled
{
name: "cline Kimi K2.6 family reasoning.enabled=false -> thinking.type=disabled",
+1 -1
View File
@@ -35,7 +35,7 @@ export type GatewayModelCapability =
export type GatewayPromptCacheStrategy = "anthropic-automatic";
export type GatewayUsageCostDisplay = "show" | "hide";
export type GatewayPromptCacheFormat = "anthropic-cache-control";
export type GatewayReasoningFormat = "anthropic-thinking";
export type GatewayReasoningFormat = "anthropic-thinking" | "glm-thinking";
export type GatewayModelRoute =
| { matcher: "anthropic-compatible" }
| {