From 5041494e1c1dfd02a396ac5002c587665713c6e6 Mon Sep 17 00:00:00 2001 From: VitzS7 Date: Sun, 5 Jul 2026 17:34:04 +0700 Subject: [PATCH] fix(kiro): deliver system prompt natively, add Opus 4.5/4.7/4.8, tolerate dash version ids (#2366) - Pass system prompt via native systemInstruction field (+ fallback) so Claude models stop treating it as info-only - Add Opus 4.5/4.7/4.8 (base/thinking/agentic/thinking+agentic) to Kiro registry - normalizeModelId(): dash->dot version separator, scoped to Kiro provider only - Replace with in claude-to-openai/openai-to-kiro Co-authored-by: Cursor --- open-sse/config/providerModels.js | 34 ++++++++++++---- open-sse/providers/models/schema.js | 9 +++++ open-sse/providers/registry/kiro.js | 19 +++++++++ open-sse/translator/request/claude-to-kiro.js | 40 ++++++++++++------- .../translator/request/claude-to-openai.js | 5 ++- open-sse/translator/request/openai-to-kiro.js | 6 ++- tests/unit/model-name-regex.test.js | 24 ++++++++++- 7 files changed, 111 insertions(+), 26 deletions(-) diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index c4cfa413..e1153da2 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -2,7 +2,7 @@ import { PROVIDERS } from "./providers.js"; import REGISTRY from "../providers/registry/index.js"; // PROVIDER_MODELS now built from providers/registry (transport + models co-located) import { PROVIDER_MODELS } from "../providers/index.js"; -import { modelQuotaFamily, modelStrip, modelTargetFormat } from "../providers/models/schema.js"; +import { modelQuotaFamily, modelStrip, modelTargetFormat, normalizeModelId } from "../providers/models/schema.js"; import { CODEX_REVIEW_SUFFIX } from "../providers/models/helpers.js"; export { PROVIDER_MODELS }; @@ -18,37 +18,55 @@ export function getDefaultModel(aliasOrId) { return models?.[0]?.id || null; } +// Providers whose registry uses dots in version numbers (e.g. "claude-sonnet-4.5"). +// For these, we tolerate clients sending dashes ("claude-sonnet-4-5") by normalizing +// digit-hyphen-digit to digit-dot-digit before lookup. Other providers are left untouched. +const DOT_VERSION_PROVIDERS = new Set(["kr", "kiro"]); + +// Find a registry entry by id. For Kiro models, tolerates dash/dot version separators +// ("claude-sonnet-4-5" ~= "claude-sonnet-4.5"). Other providers use exact match only. +function findModel(models, modelId, aliasOrId) { + if (!models) return undefined; + const found = models.find(m => m.id === modelId); + if (found) return found; + if (!DOT_VERSION_PROVIDERS.has(aliasOrId)) return undefined; + const normalized = normalizeModelId(modelId); + if (normalized === modelId) return undefined; + return models.find(m => m.id === normalized); +} + export function isValidModel(aliasOrId, modelId, passthroughProviders = new Set()) { if (passthroughProviders.has(aliasOrId)) return true; const models = PROVIDER_MODELS[aliasOrId]; if (!models) return false; - return models.some(m => m.id === modelId); + return !!findModel(models, modelId, aliasOrId); } export function findModelName(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return modelId; - const found = models.find(m => m.id === modelId); + const found = findModel(models, modelId, aliasOrId); return found?.name || modelId; } export function getModelTargetFormat(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; - return modelTargetFormat(models.find(m => m.id === modelId)); + return modelTargetFormat(findModel(models, modelId, aliasOrId)); } export function getModelType(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; if (!models) return null; - const found = models.find(m => m.id === modelId); + const found = findModel(models, modelId, aliasOrId); return found?.kind || found?.type || null; } export function getModelUpstreamId(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; - const found = models?.find(m => m.id === modelId); + const found = findModel(models, modelId, aliasOrId); if (found?.upstreamModelId) return found.upstreamModelId; + if (found?.id) return found.id; if (aliasOrId === "cx" && typeof modelId === "string" && modelId.endsWith(CODEX_REVIEW_SUFFIX)) { return modelId.slice(0, -CODEX_REVIEW_SUFFIX.length); } @@ -57,7 +75,7 @@ export function getModelUpstreamId(aliasOrId, modelId) { export function getModelQuotaFamily(aliasOrId, modelId) { const models = PROVIDER_MODELS[aliasOrId]; - return modelQuotaFamily(models?.find(m => m.id === modelId)); + return modelQuotaFamily(findModel(models, modelId, aliasOrId)); } // OAuth short aliases — derived from registry `alias` (single source). everything else: alias = id. @@ -79,5 +97,5 @@ export function getModelsByProviderId(providerId) { // Get strip list for a model entry (explicit opt-in only) // Returns array of content types to strip, e.g. ["image", "audio"] export function getModelStrip(alias, modelId) { - return modelStrip(PROVIDER_MODELS[alias]?.find(m => m.id === modelId)); + return modelStrip(findModel(PROVIDER_MODELS[alias], modelId, alias)); } diff --git a/open-sse/providers/models/schema.js b/open-sse/providers/models/schema.js index 8be351ad..c14a3d58 100644 --- a/open-sse/providers/models/schema.js +++ b/open-sse/providers/models/schema.js @@ -1,5 +1,14 @@ import { deriveModelName } from "./namePatterns.js"; +// Normalize version separators in a model id: hyphen between two digits becomes a dot. +// Registry ids use dots for versions ("claude-sonnet-4.5") but clients (CLIs, aliases) +// often send them with dashes ("claude-sonnet-4-5"). Only digit-digit hyphens are +// touched, so word/suffix hyphens stay intact ("-thinking", "-agentic", "qwen3-coder-next"). +export function normalizeModelId(modelId) { + if (typeof modelId !== "string") return modelId; + return modelId.replace(/(\d)-(\d)/g, "$1.$2"); +} + // Model defaults centralized (was scattered as `m.kind || "llm"`, `quotaFamily || "normal"`, etc.) export const MODEL_DEFAULTS = { kind: "llm", diff --git a/open-sse/providers/registry/kiro.js b/open-sse/providers/registry/kiro.js index 12015643..dab23a3f 100644 --- a/open-sse/providers/registry/kiro.js +++ b/open-sse/providers/registry/kiro.js @@ -42,19 +42,38 @@ export default { }, }, models: [ + // Opus (added per kiro.dev/changelog/models and kiro.dev/docs/models) + { id: "claude-opus-4.8", name: "Claude Opus 4.8" }, + { id: "claude-opus-4.8-thinking", name: "Claude Opus 4.8 (Thinking)" }, + { id: "claude-opus-4.8-agentic", name: "Claude Opus 4.8 (Agentic)" }, + { id: "claude-opus-4.8-thinking-agentic", name: "Claude Opus 4.8 (Thinking + Agentic)" }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7" }, + { id: "claude-opus-4.7-thinking", name: "Claude Opus 4.7 (Thinking)" }, + { id: "claude-opus-4.7-agentic", name: "Claude Opus 4.7 (Agentic)" }, + { id: "claude-opus-4.7-thinking-agentic", name: "Claude Opus 4.7 (Thinking + Agentic)" }, + { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, + { id: "claude-opus-4.5-thinking", name: "Claude Opus 4.5 (Thinking)" }, + { id: "claude-opus-4.5-agentic", name: "Claude Opus 4.5 (Agentic)" }, + { id: "claude-opus-4.5-thinking-agentic", name: "Claude Opus 4.5 (Thinking + Agentic)" }, + // Sonnet { id: "claude-sonnet-5", name: "Claude Sonnet 5" }, { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5" }, + // Haiku { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, + // Non-Anthropic { id: "deepseek-3.2", name: "DeepSeek 3.2", strip: ["image","audio"] }, { id: "qwen3-coder-next", name: "Qwen3 Coder Next", strip: ["image","audio"] }, { id: "glm-5", name: "GLM 5" }, { id: "MiniMax-M2.5", name: "MiniMax M2.5" }, + // Thinking variants { id: "claude-sonnet-5-thinking", name: "Claude Sonnet 5 (Thinking)" }, { id: "claude-sonnet-4.5-thinking", name: "Claude Sonnet 4.5 (Thinking)" }, { id: "claude-haiku-4.5-thinking", name: "Claude Haiku 4.5 (Thinking)" }, + // Agentic variants { id: "claude-sonnet-5-agentic", name: "Claude Sonnet 5 (Agentic)" }, { id: "claude-sonnet-4.5-agentic", name: "Claude Sonnet 4.5 (Agentic)" }, { id: "claude-haiku-4.5-agentic", name: "Claude Haiku 4.5 (Agentic)" }, + // Thinking + Agentic variants { id: "claude-sonnet-5-thinking-agentic", name: "Claude Sonnet 5 (Thinking + Agentic)" }, { id: "claude-sonnet-4.5-thinking-agentic", name: "Claude Sonnet 4.5 (Thinking + Agentic)" }, { id: "claude-haiku-4.5-thinking-agentic", name: "Claude Haiku 4.5 (Thinking + Agentic)" }, diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 5d891c11..c42823b6 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -404,7 +404,10 @@ export function claudeToKiroRequest(model, body, stream, credentials) { let finalContent = currentMessage?.userInputMessage?.content || ""; - // System prompt → prepend to the user content. + // System prompt: pass via native systemInstruction field (Kiro/Q API supports it) + // and also prepend as in user content as fallback for upstreams + // that don't support the native field. + let systemInstruction = undefined; if (body.system) { let systemText = ""; if (typeof body.system === "string") { @@ -412,7 +415,10 @@ export function claudeToKiroRequest(model, body, stream, credentials) { } else if (Array.isArray(body.system)) { systemText = body.system.map((s) => s.text || "").join("\n"); } - if (systemText) finalContent = `${systemText}\n\n${finalContent}`; + if (systemText) { + systemInstruction = systemText; + finalContent = `\n${systemText}\n\n\n${finalContent}`; + } } // Prefix order: thinking_mode tag, timestamp marker, then agentic prompt. @@ -423,23 +429,29 @@ export function claudeToKiroRequest(model, body, stream, credentials) { if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT); finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`; + const userInputMessage = { + content: finalContent, + modelId: upstreamModel, + origin: "AI_EDITOR", + ...(currentMessage?.userInputMessage?.userInputMessageContext && { + userInputMessageContext: + currentMessage.userInputMessage.userInputMessageContext, + }), + ...(currentMessage?.userInputMessage?.images && { + images: currentMessage.userInputMessage.images, + }), + }; + + if (systemInstruction) { + userInputMessage.systemInstruction = systemInstruction; + } + const payload = { conversationState: { chatTriggerType: "MANUAL", conversationId: uuidv4(), currentMessage: { - userInputMessage: { - content: finalContent, - modelId: upstreamModel, - origin: "AI_EDITOR", - ...(currentMessage?.userInputMessage?.userInputMessageContext && { - userInputMessageContext: - currentMessage.userInputMessage.userInputMessageContext, - }), - ...(currentMessage?.userInputMessage?.images && { - images: currentMessage.userInputMessage.images, - }), - }, + userInputMessage, }, history, }, diff --git a/open-sse/translator/request/claude-to-openai.js b/open-sse/translator/request/claude-to-openai.js index c6e92ed6..3956f828 100644 --- a/open-sse/translator/request/claude-to-openai.js +++ b/open-sse/translator/request/claude-to-openai.js @@ -129,14 +129,15 @@ function fixMissingToolResponsesOpenAI(messages) { } } -// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400) +// Wrap mid-conversation system text so it ends as a user turn (avoids Anthropic prefill 400). +// Uses tags that Claude models treat as authoritative directives. function systemReminderText(content) { const parts = Array.isArray(content) ? content.filter(c => c?.type === CLAUDE_BLOCK.TEXT).map(c => c.text || "") : [typeof content === "string" ? content : ""]; const text = parts.filter(Boolean).join("\n"); if (!text.trim()) return ""; - return `\n${text}\n`; + return `\n${text}\n`; } // Convert single Claude message - returns single message or array of messages diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index ee886666..5c8cc00f 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -270,6 +270,7 @@ function convertMessages(messages, tools, model) { let role = msg.role; // Normalize: system/tool -> user + const wasSystem = role === ROLE.SYSTEM; if (role === ROLE.SYSTEM || role === ROLE.TOOL) { role = ROLE.USER; } @@ -338,7 +339,10 @@ function convertMessages(messages, tools, model) { content: [{ text: toolContent }] }); } else if (content) { - pendingUserContent.push(content); + // tags: Claude models treat these as authoritative directives. + pendingUserContent.push( + wasSystem ? `\n${content}\n` : content + ); } } else if (role === ROLE.ASSISTANT) { // Extract text content and tool uses diff --git a/tests/unit/model-name-regex.test.js b/tests/unit/model-name-regex.test.js index f07ace0f..49a16605 100644 --- a/tests/unit/model-name-regex.test.js +++ b/tests/unit/model-name-regex.test.js @@ -1,7 +1,7 @@ // Guards C2: regex name fallback (no catalog). Terse entries derive name; existing names untouched. import { describe, it, expect } from "vitest"; import { deriveModelName } from "../../open-sse/providers/models/namePatterns.js"; -import { normalizeModel } from "../../open-sse/providers/models/schema.js"; +import { normalizeModel, normalizeModelId } from "../../open-sse/providers/models/schema.js"; describe("model name regex fallback (C2)", () => { it("derives display name from id per family", () => { @@ -25,4 +25,26 @@ describe("model name regex fallback (C2)", () => { expect(m.id).toBe("glm-5"); expect(m.name).toBe("GLM 5"); }); + + it("normalizeModelId: dash between digits becomes a dot (version separator)", () => { + expect(normalizeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4.5"); + expect(normalizeModelId("minimax-m2-5")).toBe("minimax-m2.5"); + expect(normalizeModelId("deepseek-3-2")).toBe("deepseek-3.2"); + }); + + it("normalizeModelId: preserves word-suffix hyphens (-thinking, -agentic)", () => { + expect(normalizeModelId("claude-sonnet-4-5-thinking")).toBe("claude-sonnet-4.5-thinking"); + expect(normalizeModelId("claude-sonnet-4-5-thinking-agentic")).toBe("claude-sonnet-4.5-thinking-agentic"); + }); + + it("normalizeModelId: leaves ids with no digit-digit hyphen untouched", () => { + expect(normalizeModelId("qwen3-coder-next")).toBe("qwen3-coder-next"); + expect(normalizeModelId("claude-sonnet-5")).toBe("claude-sonnet-5"); + expect(normalizeModelId("glm-5")).toBe("glm-5"); + }); + + it("normalizeModelId: non-string input passes through", () => { + expect(normalizeModelId(undefined)).toBeUndefined(); + expect(normalizeModelId(null)).toBeNull(); + }); });