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 (+ <instructions> fallback)
  so Claude models stop treating it as info-only <system-reminder>
- 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 <system-reminder> with <instructions> in claude-to-openai/openai-to-kiro

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
VitzS7
2026-07-05 17:34:04 +07:00
committed by decolua
parent 7f436e2792
commit 5041494e1c
7 changed files with 111 additions and 26 deletions

View File

@@ -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));
}

View File

@@ -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",

View File

@@ -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)" },

View File

@@ -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 <instructions> 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 = `<instructions>\n${systemText}\n</instructions>\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,
},

View File

@@ -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 <instructions> 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 `<system-reminder>\n${text}\n</system-reminder>`;
return `<instructions>\n${text}\n</instructions>`;
}
// Convert single Claude message - returns single message or array of messages

View File

@@ -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);
// <instructions> tags: Claude models treat these as authoritative directives.
pendingUserContent.push(
wasSystem ? `<instructions>\n${content}\n</instructions>` : content
);
}
} else if (role === ROLE.ASSISTANT) {
// Extract text content and tool uses

View File

@@ -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();
});
});