diff --git a/open-sse/translator/helpers/responsesApiHelper.ts b/open-sse/translator/helpers/responsesApiHelper.ts index 179ae39344..1f856b11f1 100644 --- a/open-sse/translator/helpers/responsesApiHelper.ts +++ b/open-sse/translator/helpers/responsesApiHelper.ts @@ -3,7 +3,15 @@ * Delegates to the canonical translator to avoid logic duplication. */ import { openaiResponsesToOpenAIRequest } from "../request/openai-responses.ts"; +import { toRecord } from "../request/openai-responses/helpers.ts"; export function convertResponsesApiFormat(body, credentials = null, provider = null) { - return openaiResponsesToOpenAIRequest(provider, body, null, credentials); + const bodyModel = toRecord(body).model; + const requestedModel = + typeof bodyModel === "string" && bodyModel.trim().length > 0 + ? bodyModel.includes("/") || typeof provider !== "string" || provider.length === 0 + ? bodyModel + : `${provider}/${bodyModel}` + : provider; + return openaiResponsesToOpenAIRequest(requestedModel, body, null, credentials); } diff --git a/open-sse/translator/request/openai-responses.ts b/open-sse/translator/request/openai-responses.ts index 4b2186d552..67c4a9eb11 100644 --- a/open-sse/translator/request/openai-responses.ts +++ b/open-sse/translator/request/openai-responses.ts @@ -724,7 +724,7 @@ export function openaiResponsesToOpenAIRequest( const reasoningRec = toRecord(root.reasoning); const effort = toString(reasoningRec.effort); if (effort && result.reasoning_effort === undefined) { - result.reasoning_effort = normalizeResponsesReasoningEffort(effort); + result.reasoning_effort = normalizeResponsesReasoningEffort(effort, model); } if ( credentialRecord._copilotClient === true && diff --git a/open-sse/translator/request/openai-responses/helpers.ts b/open-sse/translator/request/openai-responses/helpers.ts index ac5f8b746d..7f31eb99ea 100644 --- a/open-sse/translator/request/openai-responses/helpers.ts +++ b/open-sse/translator/request/openai-responses/helpers.ts @@ -52,13 +52,18 @@ export function imageUrlToText(value: unknown): string { const CODEX_GPT_5_6_MODEL_PATTERN = /^gpt-5\.6-(?:sol|terra|luna)(?:-(?:none|low|medium|high|xhigh|max|ultra))?$/; +const KIRO_GPT_5_6_MODEL_PATTERN = + /^(?:kiro|kr)\/gpt-5\.6-(?:sol|terra|luna)(?:-(?:none|low|medium|high|xhigh|max))?$/; function supportsNativeMaxReasoningEffort(model: unknown): boolean { const normalizedModel = toString(model) .trim() .toLowerCase() .replace(/^(?:codex|cx)\//, ""); - return CODEX_GPT_5_6_MODEL_PATTERN.test(normalizedModel); + return ( + CODEX_GPT_5_6_MODEL_PATTERN.test(normalizedModel) || + KIRO_GPT_5_6_MODEL_PATTERN.test(toString(model).trim().toLowerCase()) + ); } export function normalizeResponsesReasoningEffort(value: unknown, model?: unknown): string { diff --git a/open-sse/translator/request/openai-to-kiro.ts b/open-sse/translator/request/openai-to-kiro.ts index b833fc02ff..17a5f6d1d6 100644 --- a/open-sse/translator/request/openai-to-kiro.ts +++ b/open-sse/translator/request/openai-to-kiro.ts @@ -14,6 +14,7 @@ import { import { resolveKiroModelAlias, supportsKiroAdaptiveThinking, + supportsKiroNativeReasoning, } from "./openai-to-kiro/adaptiveThinking.ts"; /** @@ -786,6 +787,7 @@ export function buildKiroPayload(model, body, stream, credentials) { topP?: number; }; additionalModelRequestFields?: { + reasoning?: { effort: string }; thinking?: { type: string; display?: string }; output_config?: { effort: string }; max_tokens?: number; @@ -870,29 +872,43 @@ export function buildKiroPayload(model, body, stream, credentials) { // thinking:{type:"adaptive"} + a clamped max_tokens), forwarded to AWS by // the Kiro executor's transformRequest allowlist — the graded effort lever, // gated on Kiro's adaptive-thinking allowlist (#6576), not supportsReasoning(). + // GPT-5.6 models use the native `reasoning:{effort}` field instead. They must + // not receive the Claude `output_config`/`thinking` envelope: Kiro rejects it + // as an unknown field for the GPT-5.6 family. const requestedEffort = resolveKiroEffort(body) || (modelRequestsThinking ? "high" : ""); - const kiroEffort = supportsKiroAdaptiveThinking(normalizedModel) ? requestedEffort : ""; + const usesNativeReasoning = supportsKiroNativeReasoning(normalizedModel); + const usesAdaptiveThinking = supportsKiroAdaptiveThinking(normalizedModel); + const kiroEffort = usesNativeReasoning || usesAdaptiveThinking ? requestedEffort : ""; if (kiroEffort) { - // `` / `` are Kiro/CodeWhisperer prompt - // conventions (NOT Anthropic API params); the length is a soft hint (the hard - // enable signal is ``), clamped to the model's thinking cap. - const thinkingLength = capThinkingBudget(normalizedModel, thinkingLengthForEffort(kiroEffort)); - const directive = - `enabled` + - `${thinkingLength}`; - payload.conversationState.currentMessage.userInputMessage.content = `${directive}\n\n${payload.conversationState.currentMessage.userInputMessage.content}`; - const fields: { - output_config: { effort: string }; - thinking: { type: string; display: string }; + reasoning?: { effort: string }; + output_config?: { effort: string }; + thinking?: { type: string; display: string }; max_tokens?: number; - } = { - output_config: { effort: kiroEffort }, - thinking: { type: "adaptive", display: "summarized" }, - }; + } = usesNativeReasoning + ? { reasoning: { effort: kiroEffort } } + : { + output_config: { effort: kiroEffort }, + thinking: { type: "adaptive", display: "summarized" }, + }; + + if (usesAdaptiveThinking) { + // `` / `` are Kiro/CodeWhisperer prompt + // conventions (NOT Anthropic API params); the length is a soft hint (the hard + // enable signal is ``), clamped to the model's thinking cap. + const thinkingLength = capThinkingBudget( + normalizedModel, + thinkingLengthForEffort(kiroEffort) + ); + const directive = + `enabled` + + `${thinkingLength}`; + payload.conversationState.currentMessage.userInputMessage.content = `${directive}\n\n${payload.conversationState.currentMessage.userInputMessage.content}`; + } + // Forward max_tokens only when the client set one, clamped to the model's // output window (floor 1024) — matches pi-kiro and avoids an over-budget reject. - if (maxTokens > 0) { + if (usesAdaptiveThinking && maxTokens > 0) { const capped = capMaxOutputTokens(normalizedModel, maxTokens) ?? maxTokens; fields.max_tokens = Math.max(Math.floor(capped), 1024); } diff --git a/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts b/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts index 338768f8d6..72ccc81951 100644 --- a/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts +++ b/open-sse/translator/request/openai-to-kiro/adaptiveThinking.ts @@ -7,17 +7,21 @@ * rejects the field for `claude-sonnet-4.5` and `claude-haiku-4.5` with a raw * upstream 400 (`additionalModelRequestFields is not supported for this * model`, issue #6576) even though both ARE thinking-capable on Anthropic's - * direct API. Only `claude-sonnet-5` is confirmed to accept the adaptive - * envelope on Kiro today — keep this allowlist in sync with - * `open-sse/config/providers/registry/kiro/index.ts` if Kiro's catalog or - * upstream behavior changes. + * direct API. `claude-sonnet-5` is confirmed to accept the adaptive envelope + * on Kiro today. GPT-5.6 models use Kiro's separate `reasoning.effort` shape, + * not this Claude adaptive envelope. */ const KIRO_ADAPTIVE_THINKING_MODELS = new Set(["claude-sonnet-5"]); +const KIRO_NATIVE_REASONING_MODELS = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]); export function supportsKiroAdaptiveThinking(normalizedModel: string): boolean { return KIRO_ADAPTIVE_THINKING_MODELS.has(normalizedModel); } +export function supportsKiroNativeReasoning(normalizedModel: string): boolean { + return KIRO_NATIVE_REASONING_MODELS.has(normalizedModel); +} + const KIRO_UNSUPPORTED_AGENTIC_MESSAGE = "Kiro agentic aliases are not supported. The '-agentic' suffix did not change the " + "upstream request; select a real Kiro model instead."; diff --git a/src/shared/reasoning/effortStandardization.ts b/src/shared/reasoning/effortStandardization.ts index f6924f4cc1..21a336556c 100644 --- a/src/shared/reasoning/effortStandardization.ts +++ b/src/shared/reasoning/effortStandardization.ts @@ -12,7 +12,8 @@ import { z } from "zod"; * mappers already read. * * The provider-agnostic vocabulary remains five values. Provider-native additions such as - * Codex GPT-5.6 Max and Ultra are exposed separately without widening this request contract. + * Codex GPT-5.6 Max/Ultra and Kiro GPT-5.6 Max are exposed separately without widening this + * request contract. */ export const CANONICAL_EFFORT_VALUES = ["none", "low", "medium", "high", "xhigh"] as const; @@ -29,16 +30,21 @@ export function extendCodexGpt56EffortValues( const normalizedModel = model ?.trim() .toLowerCase() - .replace(/^(?:codex|cx)\//, ""); - if (!normalizedModel || (normalizedProvider !== "codex" && normalizedProvider !== "cx")) { - return values; - } + .replace(/^(?:codex|cx|kiro|kr)\//, ""); + if (!normalizedModel) return values; const match = normalizedModel.match( /^gpt-5\.6-(sol|terra|luna)(?:-(?:none|low|medium|high|xhigh|max|ultra))?$/ ); if (!match) return values; + const isKiroProvider = normalizedProvider === "kiro" || normalizedProvider === "kr"; + if (isKiroProvider) { + return values.includes("max") ? values : [...values, "max"]; + } + + if (normalizedProvider !== "codex" && normalizedProvider !== "cx") return values; + const nativeValues = ["low", "medium", "high", "xhigh", "max"]; return match[1] === "luna" ? nativeValues : [...nativeValues, "ultra"]; } diff --git a/tests/unit/catalog-helpers-extraction.test.ts b/tests/unit/catalog-helpers-extraction.test.ts index 48ce364353..7fa31de3ac 100644 --- a/tests/unit/catalog-helpers-extraction.test.ts +++ b/tests/unit/catalog-helpers-extraction.test.ts @@ -15,6 +15,7 @@ import { intersectStringArrays, minKnownNumber, maybeOmitCatalogModelName, + getThinkingCapabilityFields, } from "../../src/app/api/v1/models/catalogHelpers.ts"; import { qualifyOpenRouterModelId, @@ -73,6 +74,16 @@ test("catalogHelpers: intersectStringArrays (dedup + common)", () => { assert.deepEqual(intersectStringArrays([["a"], []]), []); }); +test("catalogHelpers: Kiro GPT-5.6 models expose the native Max tier", () => { + for (const model of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) { + assert.deepEqual(getThinkingCapabilityFields("kr", model, true), { + thinking: true, + supportsThinking: true, + effort_tiers: ["none", "low", "medium", "high", "xhigh", "max"], + }); + } +}); + test("catalogHelpers: minKnownNumber ignores non-positive/unknown", () => { assert.equal(minKnownNumber([3, 1, 2]), 1); assert.equal(minKnownNumber([undefined, 0, -5, 7]), 7); diff --git a/tests/unit/effort-thinking-standardization-6241.test.ts b/tests/unit/effort-thinking-standardization-6241.test.ts index bdc354537d..1ea9bff49f 100644 --- a/tests/unit/effort-thinking-standardization-6241.test.ts +++ b/tests/unit/effort-thinking-standardization-6241.test.ts @@ -9,15 +9,10 @@ import path from "node:path"; const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-effort-6241-")); process.env.DATA_DIR = TEST_DATA_DIR; -const { - CANONICAL_EFFORT_VALUES, - normalizeEffort, - effortRequestSchema, - normalizeReasoningRequest, -} = await import("../../src/shared/reasoning/effortStandardization.ts"); -const { providerChatCompletionSchema } = await import( - "../../src/shared/validation/schemas/apiV1.ts" -); +const { CANONICAL_EFFORT_VALUES, normalizeEffort, effortRequestSchema, normalizeReasoningRequest } = + await import("../../src/shared/reasoning/effortStandardization.ts"); +const { providerChatCompletionSchema } = + await import("../../src/shared/validation/schemas/apiV1.ts"); const core = await import("../../src/lib/db/core.ts"); const modelsDevSync = await import("../../src/lib/modelsDevSync.ts"); const registry = await import("../../src/lib/modelMetadataRegistry.ts"); @@ -183,3 +178,16 @@ test("enrichCatalogModelEntry exposes supportsThinking + effort_tiers for a thin assert.equal(caps.thinking, true); assert.equal(caps.reasoning, true); }); + +test("enrichCatalogModelEntry exposes Max for Kiro GPT-5.6 Luna", () => { + const enriched = registry.enrichCatalogModelEntry({ + id: "kr/gpt-5.6-luna", + object: "model", + owned_by: "kr", + root: "gpt-5.6-luna", + }) as Record; + + const caps = enriched.capabilities as Record; + assert.equal(caps.supportsThinking, true); + assert.deepEqual(caps.effort_tiers, ["none", "low", "medium", "high", "xhigh", "max"]); +}); diff --git a/tests/unit/openai-responses-reasoning-effort.test.ts b/tests/unit/openai-responses-reasoning-effort.test.ts index 747720426a..e14961166b 100644 --- a/tests/unit/openai-responses-reasoning-effort.test.ts +++ b/tests/unit/openai-responses-reasoning-effort.test.ts @@ -20,6 +20,7 @@ import { openaiResponsesToOpenAIRequest, } from "../../open-sse/translator/request/openai-responses.ts"; import { convertResponsesApiFormat } from "../../open-sse/translator/helpers/responsesApiHelper.ts"; +import { buildKiroPayload } from "../../open-sse/translator/request/openai-to-kiro.ts"; function asRecord(value: unknown): Record { return value as Record; @@ -61,6 +62,34 @@ test("Responses -> Chat preserves reasoning.effort via the helper wrapper", () = assert.equal(out.reasoning, undefined); }); +test("Responses -> Kiro preserves literal Max for GPT-5.6 models", () => { + for (const model of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) { + const converted = asRecord( + convertResponsesApiFormat( + { + model: `kr/${model}`, + input: "hello", + reasoning: { effort: "max" }, + }, + null, + "kiro" + ) + ); + + assert.equal(converted.reasoning_effort, "max"); + + const payload = buildKiroPayload(model, converted, false, null); + assert.equal(payload.additionalModelRequestFields?.reasoning?.effort, "max"); + assert.equal(payload.additionalModelRequestFields?.output_config, undefined); + assert.equal(payload.additionalModelRequestFields?.thinking, undefined); + assert.equal(payload.additionalModelRequestFields?.max_tokens, undefined); + assert.doesNotMatch( + payload.conversationState.currentMessage.userInputMessage.content, + // + ); + } +}); + test("Responses -> Chat does not overwrite an explicit reasoning_effort", () => { const out = asRecord( openaiResponsesToOpenAIRequest( diff --git a/tests/unit/translator-openai-to-kiro.test.ts b/tests/unit/translator-openai-to-kiro.test.ts index 803aa39fd2..5796e96aac 100644 --- a/tests/unit/translator-openai-to-kiro.test.ts +++ b/tests/unit/translator-openai-to-kiro.test.ts @@ -1135,6 +1135,31 @@ test("buildKiroPayload enables thinking mode for Claude models via reasoning_eff ); }); +test("buildKiroPayload uses native Max reasoning for Kiro GPT-5.6 models", () => { + for (const model of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) { + const result = buildKiroPayload( + model, + { + messages: [{ role: "user", content: "Solve a hard problem" }], + reasoning_effort: "max", + max_tokens: 64000, + }, + false, + null + ); + + assert.ok(result.additionalModelRequestFields, "Max reasoning must be forwarded to Kiro"); + assert.equal(result.additionalModelRequestFields.reasoning.effort, "max"); + assert.equal(result.additionalModelRequestFields.output_config, undefined); + assert.equal(result.additionalModelRequestFields.thinking, undefined); + assert.equal(result.additionalModelRequestFields.max_tokens, undefined); + assert.doesNotMatch( + result.conversationState.currentMessage.userInputMessage.content, + // + ); + } +}); + test("buildKiroPayload drops temperature when thinking is enabled", () => { const body = { messages: [{ role: "user", content: "Solve a hard problem" }],