diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js index e2158516..3ab8abfe 100644 --- a/open-sse/executors/antigravity.js +++ b/open-sse/executors/antigravity.js @@ -18,6 +18,9 @@ function sanitizeFunctionName(name) { const MAX_RETRY_AFTER_MS = 10000; const MAX_ANTIGRAVITY_OUTPUT_TOKENS = 16384; +// Fields Google generateContent rejects (e.g. Claude adaptive output_config) — stripped from antigravity request envelope +const ANTIGRAVITY_REQUEST_BLACKLIST = ["output_config"]; + export class AntigravityExecutor extends BaseExecutor { constructor() { super("antigravity", PROVIDERS.antigravity); @@ -83,7 +86,9 @@ export class AntigravityExecutor extends BaseExecutor { tools = allDeclarations.length > 0 ? [{ functionDeclarations: allDeclarations }] : []; } + // Strip tools/toolConfig (handled separately) and blacklisted fields that Google rejects const { tools: _originalTools, toolConfig: _originalToolConfig, ...requestWithoutTools } = body.request || {}; + for (const key of ANTIGRAVITY_REQUEST_BLACKLIST) delete requestWithoutTools[key]; const generationConfig = { ...(requestWithoutTools.generationConfig || {}) }; if (generationConfig.maxOutputTokens > MAX_ANTIGRAVITY_OUTPUT_TOKENS) { generationConfig.maxOutputTokens = MAX_ANTIGRAVITY_OUTPUT_TOKENS; diff --git a/open-sse/executors/xiaomi-tokenplan.js b/open-sse/executors/xiaomi-tokenplan.js index 259da07f..a1df9142 100644 --- a/open-sse/executors/xiaomi-tokenplan.js +++ b/open-sse/executors/xiaomi-tokenplan.js @@ -1,19 +1,20 @@ import { DefaultExecutor } from "./default.js"; import { resolveXiaomiTokenplanBaseUrl } from "../config/providers.js"; -import { getModelTargetFormat } from "../config/providerModels.js"; -import { FORMATS } from "../translator/formats.js"; +// import { getModelTargetFormat } from "../config/providerModels.js"; +// import { FORMATS } from "../translator/formats.js"; export class XiaomiTokenplanExecutor extends DefaultExecutor { constructor() { super("xiaomi-tokenplan"); } - // Claude-native aliases route to the Anthropic-compatible messages endpoint + // Token Plan keys are region-specific — always OpenAI-compatible /chat/completions buildUrl(model, stream, urlIndex = 0, credentials = null) { const baseUrl = resolveXiaomiTokenplanBaseUrl(credentials); - if (getModelTargetFormat(model, model) === FORMATS.CLAUDE) { - return `${baseUrl.replace(/\/v1\/?$/, "/anthropic/v1")}/messages`; - } + // Claude-native aliases route to the Anthropic-compatible messages endpoint + // if (getModelTargetFormat(this.provider, model) === FORMATS.CLAUDE) { + // return `${baseUrl.replace(/\/v1\/?$/, "/anthropic/v1")}/messages`; + // } return `${baseUrl}/chat/completions`; } } diff --git a/open-sse/providers/registry/antigravity.js b/open-sse/providers/registry/antigravity.js index 2f71c482..17abd64d 100644 --- a/open-sse/providers/registry/antigravity.js +++ b/open-sse/providers/registry/antigravity.js @@ -29,7 +29,7 @@ export default { }, retry: { "429": { - attempts: 6, + attempts: 3, }, "503": { attempts: 3, diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index 3e2e6f76..aeb41365 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -30,6 +30,7 @@ import { isThinkingEnabled, buildThinkingSystemPrefix, KIRO_AGENTIC_SYSTEM_PROMPT, + resolveDefaultProfileArn, } from "../../config/kiroConstants.js"; import { DEFAULT_IMAGE_MIME } from "../schema/index.js"; import { ROLE, CLAUDE_BLOCK } from "../schema/index.js"; @@ -397,7 +398,11 @@ export function claudeToKiroRequest(model, body, stream, credentials) { reconcileOrphanedToolResults(history, currentMessage); } - const profileArn = credentials?.providerSpecificData?.profileArn || ""; + // API-key auth must never use the shared default ARN (403); OAuth/social fall back to it. + const authMethod = credentials?.providerSpecificData?.authMethod; + const profileArn = authMethod === "api_key" + ? (credentials?.providerSpecificData?.profileArn || "") + : (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod)); let finalContent = currentMessage?.userInputMessage?.content || ""; diff --git a/package.json b/package.json index 17f8380a..a70f66fe 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@monaco-editor/react": "^4.7.0", + "@next/third-parties": "^16.2.9", "@xyflow/react": "^12.10.1", "bcryptjs": "^3.0.3", "confbox": "^0.2.4", diff --git a/src/app/layout.js b/src/app/layout.js index b6c125c9..f86cca69 100644 --- a/src/app/layout.js +++ b/src/app/layout.js @@ -1,4 +1,5 @@ import { Inter } from "next/font/google"; +import { GoogleAnalytics } from "@next/third-parties/google"; import "material-symbols/outlined.css"; import "./globals.css"; import { ThemeProvider } from "@/shared/components/ThemeProvider"; @@ -43,6 +44,7 @@ export default function RootLayout({ children }) { {children} + {gaId && } ); diff --git a/tests/translator/real/antigravity-models.real.test.js b/tests/translator/real/antigravity-models.real.test.js new file mode 100644 index 00000000..2b0886dd --- /dev/null +++ b/tests/translator/real/antigravity-models.real.test.js @@ -0,0 +1,127 @@ +// REAL integration test: hits localhost:20128/v1 with real API key for all antigravity models. +// Verifies tool-call with optional field in schema doesn't cause 400 INVALID_ARGUMENT. +// +// RUN_REAL=1 npx vitest run --config tests/vitest.config.js tests/translator/real/antigravity-models.real.test.js +// RUN_REAL=1 AG_URL=http://localhost:20128/v1 AG_KEY=sk-xxx npx vitest run ... +// +import { describe, it, expect } from "vitest"; + +const RUN_REAL = process.env.RUN_REAL === "1"; +const BASE_URL = process.env.AG_URL || "http://localhost:20128/v1"; +const API_KEY = process.env.AG_KEY; +const TIMEOUT_MS = 90000; + +// All antigravity models (from providers/registry/antigravity.js) +const AG_MODELS = [ + "ag/gemini-3-flash-agent", + "ag/gemini-3.5-flash-low", + "ag/gemini-3.5-flash-extra-low", + "ag/gemini-pro-agent", + "ag/gemini-3.1-pro-low", + "ag/claude-sonnet-4-6", + "ag/claude-opus-4-6-thinking", + "ag/gpt-oss-120b-medium", + "ag/gemini-3-flash", +]; + +// Simple text prompt — no tools +const SIMPLE_BODY = (model) => ({ + model, + stream: false, + max_tokens: 32, + messages: [{ role: "user", content: "Reply with the single word: hi" }], +}); + +// Tool call with `optional` field in schema — this is the bug scenario +const TOOL_BODY = (model) => ({ + model, + stream: false, + max_tokens: 64, + messages: [{ role: "user", content: "What is 2+2?" }], + tools: [ + { + type: "function", + function: { + name: "calculate", + description: "Perform arithmetic", + parameters: { + type: "object", + properties: { + expression: { type: "string", description: "The math expression", optional: true }, + precision: { type: "number", description: "Decimal precision", optional: true }, + note: { type: "string", optional: true }, + }, + required: [], + }, + }, + }, + ], +}); + +async function callChat(body) { + const res = await fetch(`${BASE_URL}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${API_KEY}`, + }, + body: JSON.stringify(body), + }); + + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch { /* non-json */ } + + return { status: res.status, text, json }; +} + +describe.skipIf(!RUN_REAL).concurrent("antigravity models — real", () => { + for (const model of AG_MODELS) { + it(`${model}: simple prompt`, async () => { + const { status, json, text } = await callChat(SIMPLE_BODY(model)); + + // Credential/quota issues are not translation bugs → warn and pass + if ([401, 402, 403, 429].includes(status)) { + console.warn(`[skip] ${model}: ${status} (credential/quota)`); + return; + } + + // Provider-side 400 (e.g. stream_options, model quirks) → skip + if (status === 400) { + console.warn(`[skip] ${model}: 400 (provider quirk): ${text.slice(0, 200)}`); + return; + } + + expect(status, `${model} simple: ${text.slice(0, 300)}`).toBe(200); + // Accept either OpenAI-shaped or raw Gemini-shaped response + const hasContent = + json?.choices?.[0]?.message?.content || + json?.choices?.[0]?.message?.tool_calls || + json?.candidates?.[0]?.content?.parts?.length > 0 || + json?.choices?.[0]?.finish_reason; + expect(hasContent, `${model} simple: no content in ${text.slice(0, 300)}`).toBeTruthy(); + }, TIMEOUT_MS); + + it(`${model}: tool call with optional field in schema`, async () => { + const { status, json, text } = await callChat(TOOL_BODY(model)); + + if ([401, 402, 403, 429].includes(status)) { + console.warn(`[skip] ${model}: ${status} (credential/quota)`); + return; + } + + // 400 with "optional" + "Cannot find field" = the specific bug + if (status === 400) { + const errMsg = json?.error?.message || text; + if (/optional.*Cannot find field|Cannot find field.*optional|Unknown name.*optional/i.test(errMsg)) { + throw new Error(`BUG: ${model} — 400 due to optional field: ${errMsg.slice(0, 300)}`); + } + // Other 400 (e.g. model doesn't support tools, stream_options issue) → skip + console.warn(`[skip] ${model}: 400 non-optional error: ${errMsg.slice(0, 200)}`); + return; + } + + expect(status, `${model} tool: ${text.slice(0, 300)}`).toBe(200); + }, TIMEOUT_MS); + } +});