From 82a1d1584be4b1bfe7b4730d4481578e5194e5de Mon Sep 17 00:00:00 2001 From: Alessandro Pogliaghi Date: Tue, 28 Jul 2026 13:10:20 +0100 Subject: [PATCH] feat(models): add Kimi K3 to Claude adapter Generated-By: PostHog Code Task-Id: dd739bea-9df5-4e0b-a5e2-42f6b8f8f207 --- packages/agent/src/adapters/base-acp-agent.ts | 12 +++++-- packages/agent/src/gateway-models.test.ts | 9 +++++ packages/agent/src/gateway-models.ts | 12 +++++++ .../src/extensions/posthog-provider/models.ts | 6 ++++ packages/shared/src/flags.ts | 1 + .../loops/components/LoopModelFields.tsx | 6 ++-- packages/ui/src/features/loops/loopModels.ts | 30 ++++++++++++---- .../sessions/components/ModelSelector.tsx | 29 +++++++++++---- .../sessions/modelOptionFilters.test.ts | 23 ++++++++++++ .../features/sessions/modelOptionFilters.ts | 35 +++++++++++++++++++ .../task-detail/hooks/usePreviewConfig.ts | 16 ++++++--- 11 files changed, 156 insertions(+), 23 deletions(-) create mode 100644 packages/ui/src/features/sessions/modelOptionFilters.test.ts diff --git a/packages/agent/src/adapters/base-acp-agent.ts b/packages/agent/src/adapters/base-acp-agent.ts index 243228a65a..eddfcf2db7 100644 --- a/packages/agent/src/adapters/base-acp-agent.ts +++ b/packages/agent/src/adapters/base-acp-agent.ts @@ -26,6 +26,8 @@ import { isAnthropicModel, isCloudflareModel, isCloudflareModelId, + isModalModel, + isModalModelId, pickAllowedModel, } from "../gateway-models"; import { Logger } from "../utils/logger"; @@ -152,7 +154,12 @@ export abstract class BaseAcpAgent implements Agent { const adapterModels = this.gatewayModels // Cloudflare models are servable on the Claude adapter too — the gateway translates the // `@cf/` path onto its Anthropic-Messages surface — so include them alongside Anthropic models. - .filter((model) => isAnthropicModel(model) || isCloudflareModel(model)); + .filter( + (model) => + isAnthropicModel(model) || + isCloudflareModel(model) || + isModalModel(model), + ); const options = adapterModels .map((model) => ({ @@ -171,7 +178,8 @@ export abstract class BaseAcpAgent implements Agent { const isClaudeAdapterModelId = (modelId: string): boolean => modelId.startsWith("claude-") || modelId.startsWith("anthropic/") || - isCloudflareModelId(modelId); + isCloudflareModelId(modelId) || + isModalModelId(modelId); let currentModelId = currentModelOverride ?? DEFAULT_GATEWAY_MODEL; diff --git a/packages/agent/src/gateway-models.test.ts b/packages/agent/src/gateway-models.test.ts index 5aa7438a8d..22272975d4 100644 --- a/packages/agent/src/gateway-models.test.ts +++ b/packages/agent/src/gateway-models.test.ts @@ -9,6 +9,8 @@ import { isAnthropicModel, isBlockedModelId, isCloudflareModel, + isModalModel, + isModalModelId, pickAllowedModel, } from "./gateway-models"; @@ -87,6 +89,13 @@ describe("formatGatewayModelName", () => { ).toBe("llama-3.1-8b-instruct"); }); + it("formats Kimi K3 for the model picker", () => { + const kimi = model("moonshotai/kimi-k3", "modal"); + expect(formatGatewayModelName(kimi)).toBe("Kimi K3"); + expect(isModalModel(kimi)).toBe(true); + expect(isModalModelId(kimi.id)).toBe(true); + }); + it("blocks deprecated Claude gateway models", () => { expect(isBlockedModelId("claude-opus-4-5")).toBe(true); expect(isBlockedModelId("claude-opus-4-6")).toBe(true); diff --git a/packages/agent/src/gateway-models.ts b/packages/agent/src/gateway-models.ts index 116d9bd7d1..940c28a4d5 100644 --- a/packages/agent/src/gateway-models.ts +++ b/packages/agent/src/gateway-models.ts @@ -178,6 +178,14 @@ export function isCloudflareModel(model: GatewayModel): boolean { return isCloudflareModelId(model.id) || model.owned_by === "cloudflare"; } +export function isModalModel(model: GatewayModel): boolean { + return isModalModelId(model.id) || model.owned_by === "modal"; +} + +export function isModalModelId(modelId: string): boolean { + return modelId === "moonshotai/kimi-k3"; +} + export interface ModelInfo { id: string; owned_by?: string; @@ -322,6 +330,10 @@ export function formatGatewayModelName(model: GatewayModel): string { return formatProviderModelName(model.id.split("/").pop() ?? model.id); } + if (isModalModel(model)) { + return formatModelId(model.id.split("/").pop() ?? model.id); + } + if (isOpenAIModel(model)) { return formatProviderModelName(stripProviderPrefix(model.id)); } diff --git a/packages/harness/src/extensions/posthog-provider/models.ts b/packages/harness/src/extensions/posthog-provider/models.ts index 5f81cba127..505cb9204d 100644 --- a/packages/harness/src/extensions/posthog-provider/models.ts +++ b/packages/harness/src/extensions/posthog-provider/models.ts @@ -208,6 +208,12 @@ const FALLBACK_GATEWAY_MODELS: GatewayModel[] = [ context_window: 128000, supports_vision: false, }, + { + id: "moonshotai/kimi-k3", + owned_by: "modal", + context_window: 262144, + supports_vision: false, + }, ]; export function fallbackModelConfigs( diff --git a/packages/shared/src/flags.ts b/packages/shared/src/flags.ts index 81c9442141..bfed2d509e 100644 --- a/packages/shared/src/flags.ts +++ b/packages/shared/src/flags.ts @@ -24,6 +24,7 @@ export const CHANNELS_LAYOUT_FLAG = "code-spaces-layout"; export const LOOPS_FLAG = "loops"; export const TASKS_PREWARM_SANDBOX_FLAG = "tasks-prewarm-sandbox"; export const GLM_MODEL_FLAG = "posthog-code-glm-model"; +export const KIMI_MODEL_FLAG = "tasks-kimi-k3"; /** Spoken narration (agent speaks via the `speak` tool). Gated for a staged rollout. */ export const SPOKEN_NARRATION_FLAG = "posthog-code-spoken-narration"; // Gates importing and relaying local MCP servers into cloud task runs. diff --git a/packages/ui/src/features/loops/components/LoopModelFields.tsx b/packages/ui/src/features/loops/components/LoopModelFields.tsx index 2c23454bbf..66b805a51d 100644 --- a/packages/ui/src/features/loops/components/LoopModelFields.tsx +++ b/packages/ui/src/features/loops/components/LoopModelFields.tsx @@ -1,5 +1,5 @@ import type { LoopSchemas } from "@posthog/api-client/loops"; -import { GLM_MODEL_FLAG } from "@posthog/shared"; +import { GLM_MODEL_FLAG, KIMI_MODEL_FLAG } from "@posthog/shared"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { Flex } from "@radix-ui/themes"; @@ -55,6 +55,7 @@ export function LoopModelFields({ disabled, }: LoopModelFieldsProps) { const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + const kimiEnabled = useFeatureFlag(KIMI_MODEL_FLAG); const configOptions = useLoopModelConfigOptions(adapter); const modelOptions = useMemo( @@ -62,10 +63,11 @@ export function LoopModelFields({ { value: DEFAULT_MODEL_VALUE, label: "Default (recommended)" }, ...loopModelOptions(adapter, configOptions, { glmEnabled, + kimiEnabled, pinnedModel: model, }), ], - [adapter, configOptions, glmEnabled, model], + [adapter, configOptions, glmEnabled, kimiEnabled, model], ); const reasoningOptions = useMemo( diff --git a/packages/ui/src/features/loops/loopModels.ts b/packages/ui/src/features/loops/loopModels.ts index 7f2946ab81..080c98a0e5 100644 --- a/packages/ui/src/features/loops/loopModels.ts +++ b/packages/ui/src/features/loops/loopModels.ts @@ -24,6 +24,10 @@ function isGlmModelId(modelId: string): boolean { return modelId.toLowerCase().includes("glm"); } +function isKimiModelId(modelId: string): boolean { + return modelId === "moonshotai/kimi-k3"; +} + // Served-catalog stand-in while the preview config loads or when the request // fails, so the picker never collapses to "Default" alone. Matches the // backend's per-adapter catalogs in process_task/utils.py minus client-blocked @@ -40,6 +44,7 @@ const FALLBACK_MODEL_OPTIONS: Record< { value: "claude-sonnet-5", label: "Claude Sonnet 5" }, { value: "claude-fable-5", label: "Claude Fable 5" }, { value: "@cf/zai-org/glm-5.2", label: "GLM-5.2" }, + { value: "moonshotai/kimi-k3", label: "Kimi K3" }, ], codex: [ { value: "gpt-5", label: "GPT-5" }, @@ -70,7 +75,11 @@ export function formatLoopModel( export function loopModelOptions( adapter: LoopSchemas.LoopRuntimeAdapterEnum, configOptions: SessionConfigOption[], - { glmEnabled, pinnedModel }: { glmEnabled: boolean; pinnedModel: string }, + { + glmEnabled, + kimiEnabled, + pinnedModel, + }: { glmEnabled: boolean; kimiEnabled?: boolean; pinnedModel: string }, ): LoopModelOption[] { const modelOption = configOptions.find( (option) => option.category === "model" || option.id === "model", @@ -84,12 +93,19 @@ export function loopModelOptions( label: option.name ?? option.value, })) : []; - const options = ( - served.length > 0 ? served : FALLBACK_MODEL_OPTIONS[adapter] - ).filter( - (option) => - glmEnabled || option.value === pinnedModel || !isGlmModelId(option.value), - ); + const options = (served.length > 0 ? served : FALLBACK_MODEL_OPTIONS[adapter]) + .filter( + (option) => + glmEnabled || + option.value === pinnedModel || + !isGlmModelId(option.value), + ) + .filter( + (option) => + kimiEnabled || + option.value === pinnedModel || + !isKimiModelId(option.value), + ); if (pinnedModel && !options.some((option) => option.value === pinnedModel)) { options.push({ value: pinnedModel, label: pinnedModel }); } diff --git a/packages/ui/src/features/sessions/components/ModelSelector.tsx b/packages/ui/src/features/sessions/components/ModelSelector.tsx index 2cc5173f8c..d168615326 100644 --- a/packages/ui/src/features/sessions/components/ModelSelector.tsx +++ b/packages/ui/src/features/sessions/components/ModelSelector.tsx @@ -1,4 +1,7 @@ -import type { SessionConfigSelectGroup } from "@agentclientprotocol/sdk"; +import type { + SessionConfigOption, + SessionConfigSelectGroup, +} from "@agentclientprotocol/sdk"; import { CaretDown } from "@phosphor-icons/react"; import type { SessionService } from "@posthog/core/sessions/sessionService"; import { SESSION_SERVICE } from "@posthog/core/sessions/sessionService"; @@ -12,11 +15,14 @@ import { DropdownMenuTrigger, MenuLabel, } from "@posthog/quill"; -import { type Adapter, GLM_MODEL_FLAG } from "@posthog/shared"; +import { type Adapter, GLM_MODEL_FLAG, KIMI_MODEL_FLAG } from "@posthog/shared"; import { gateRestrictedModelPick } from "@posthog/ui/features/billing/modelGate"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; import { ModelRadioItem } from "@posthog/ui/features/sessions/components/ModelRadioItem"; -import { stripGlmModelOption } from "@posthog/ui/features/sessions/modelOptionFilters"; +import { + stripGlmModelOption, + stripKimiModelOption, +} from "@posthog/ui/features/sessions/modelOptionFilters"; import { flattenSelectOptions, useModelConfigOptionForTask, @@ -44,10 +50,10 @@ export function ModelSelector({ const sessionIsCloud = useSessionIsCloud(taskId); const rawModelOption = useModelConfigOptionForTask(taskId); const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); - const modelOption = - glmEnabled || !rawModelOption - ? rawModelOption - : stripGlmModelOption(rawModelOption); + const kimiEnabled = useFeatureFlag(KIMI_MODEL_FLAG); + const modelOption = rawModelOption + ? stripDisabledPreviewModels(rawModelOption, glmEnabled, kimiEnabled) + : rawModelOption; const selectOption = modelOption?.type === "select" ? modelOption : undefined; const options = selectOption @@ -133,3 +139,12 @@ export function ModelSelector({ ); } + +function stripDisabledPreviewModels( + option: SessionConfigOption, + glmEnabled: boolean, + kimiEnabled: boolean, +): SessionConfigOption { + const withoutGlm = glmEnabled ? option : stripGlmModelOption(option); + return kimiEnabled ? withoutGlm : stripKimiModelOption(withoutGlm); +} diff --git a/packages/ui/src/features/sessions/modelOptionFilters.test.ts b/packages/ui/src/features/sessions/modelOptionFilters.test.ts new file mode 100644 index 0000000000..fefd0a9a79 --- /dev/null +++ b/packages/ui/src/features/sessions/modelOptionFilters.test.ts @@ -0,0 +1,23 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { stripKimiModelOption } from "./modelOptionFilters"; + +describe("modelOptionFilters", () => { + it("selects an available model when Kimi is hidden", () => { + const option: SessionConfigOption = { + type: "select", + id: "model", + name: "Model", + currentValue: "moonshotai/kimi-k3", + options: [ + { value: "moonshotai/kimi-k3", name: "Kimi K3" }, + { value: "claude-opus-4-8", name: "Claude Opus 4.8" }, + ], + }; + + expect(stripKimiModelOption(option)).toMatchObject({ + currentValue: "claude-opus-4-8", + options: [{ value: "claude-opus-4-8" }], + }); + }); +}); diff --git a/packages/ui/src/features/sessions/modelOptionFilters.ts b/packages/ui/src/features/sessions/modelOptionFilters.ts index d7caec3c55..3c4b87194b 100644 --- a/packages/ui/src/features/sessions/modelOptionFilters.ts +++ b/packages/ui/src/features/sessions/modelOptionFilters.ts @@ -25,3 +25,38 @@ export function stripGlmModelOption( ), }; } + +export function stripKimiModelOption( + option: SessionConfigOption, +): SessionConfigOption { + if (option.type !== "select") return option; + + if (isSelectGroup(option.options)) { + const options = option.options.map((group) => ({ + ...group, + options: group.options.filter( + (model) => model.value !== "moonshotai/kimi-k3", + ), + })); + return { + ...option, + options, + currentValue: + option.currentValue === "moonshotai/kimi-k3" + ? (options.flatMap((group) => group.options)[0]?.value ?? "") + : option.currentValue, + }; + } + + const options = option.options.filter( + (model) => model.value !== "moonshotai/kimi-k3", + ); + return { + ...option, + options, + currentValue: + option.currentValue === "moonshotai/kimi-k3" + ? (options[0]?.value ?? "") + : option.currentValue, + }; +} diff --git a/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts index 2cc286788b..ccf7bd9b9c 100644 --- a/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts +++ b/packages/ui/src/features/task-detail/hooks/usePreviewConfig.ts @@ -11,8 +11,12 @@ import { defaultEligibleModel, GLM_MODEL_FLAG, getCloudUrlFromRegion, + KIMI_MODEL_FLAG, } from "@posthog/shared"; -import { stripGlmModelOption } from "@posthog/ui/features/sessions/modelOptionFilters"; +import { + stripGlmModelOption, + stripKimiModelOption, +} from "@posthog/ui/features/sessions/modelOptionFilters"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { logger } from "../../../shell/logger"; import { useAuthStateValue } from "../../auth/store"; @@ -48,6 +52,7 @@ function getOptionByCategory( export function usePreviewConfig(adapter: Adapter): PreviewConfigResult { const hostClient = useHostTRPCClient(); const glmEnabled = useFeatureFlag(GLM_MODEL_FLAG); + const kimiEnabled = useFeatureFlag(KIMI_MODEL_FLAG); const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const apiHost = useMemo( () => (cloudRegion ? getCloudUrlFromRegion(cloudRegion) : null), @@ -83,9 +88,10 @@ export function usePreviewConfig(adapter: Adapter): PreviewConfigResult { .then((serverOptions) => { if (abort.signal.aborted) return; - const options = glmEnabled - ? serverOptions - : serverOptions.map(stripGlmModelOption); + const options = serverOptions.map((option) => { + const withoutGlm = glmEnabled ? option : stripGlmModelOption(option); + return kimiEnabled ? withoutGlm : stripKimiModelOption(withoutGlm); + }); const { defaultInitialTaskMode, @@ -145,7 +151,7 @@ export function usePreviewConfig(adapter: Adapter): PreviewConfigResult { return () => { abort.abort(); }; - }, [adapter, apiHost, hostClient, hasHydrated, glmEnabled]); + }, [adapter, apiHost, hostClient, hasHydrated, glmEnabled, kimiEnabled]); const setConfigOption = useCallback( (configId: string, value: string) => {