mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-05 06:42:12 +03:00
Merge pull request #254 from diegosouzapw/fix/issue-253-antigravity-streaming
✅ Approved — fixes confirmed. - Fix #253: Antigravity/Gemini streaming chunks now render as continuous text in Claude Code (content_block kept open across chunks) - Bonus: Download opencode.json button in Agents dashboard
This commit is contained in:
@@ -5,6 +5,10 @@ import { FORMATS } from "../formats.ts";
|
||||
* Direct Gemini → Claude response translator.
|
||||
* Converts Gemini streaming chunks directly to Claude Messages API
|
||||
* streaming events, skipping the OpenAI hub intermediate step.
|
||||
*
|
||||
* Fix (issue #253): Keep the text content_block open across streaming chunks
|
||||
* instead of opening+closing it on every chunk. This prevents Claude Code
|
||||
* from rendering each delta on a separate line.
|
||||
*/
|
||||
export function geminiToClaudeResponse(chunk, state) {
|
||||
if (!chunk) return null;
|
||||
@@ -22,6 +26,8 @@ export function geminiToClaudeResponse(chunk, state) {
|
||||
state.messageId = response.responseId || `msg_${Date.now()}`;
|
||||
state.model = response.modelVersion || "gemini";
|
||||
state.contentBlockIndex = 0;
|
||||
// Track open text block so we can keep it open across chunks
|
||||
state.openTextBlockIdx = null;
|
||||
|
||||
results.push({
|
||||
type: "message_start",
|
||||
@@ -44,8 +50,13 @@ export function geminiToClaudeResponse(chunk, state) {
|
||||
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
|
||||
const isThought = part.thought === true;
|
||||
|
||||
// Thinking content → thinking block
|
||||
// Thinking content → thinking block (always open+close per chunk)
|
||||
if (isThought && part.text) {
|
||||
// Close any open text block first
|
||||
if (state.openTextBlockIdx !== null) {
|
||||
results.push({ type: "content_block_stop", index: state.openTextBlockIdx });
|
||||
state.openTextBlockIdx = null;
|
||||
}
|
||||
const idx = state.contentBlockIndex++;
|
||||
results.push({
|
||||
type: "content_block_start",
|
||||
@@ -61,8 +72,13 @@ export function geminiToClaudeResponse(chunk, state) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Function call → tool_use block (with or without thoughtSignature)
|
||||
// Function call → tool_use block
|
||||
if (part.functionCall) {
|
||||
// Close any open text block first
|
||||
if (state.openTextBlockIdx !== null) {
|
||||
results.push({ type: "content_block_stop", index: state.openTextBlockIdx });
|
||||
state.openTextBlockIdx = null;
|
||||
}
|
||||
const fc = part.functionCall;
|
||||
const idx = state.contentBlockIndex++;
|
||||
const toolId = fc.id || `toolu_${Date.now()}_${idx}`;
|
||||
@@ -78,7 +94,6 @@ export function geminiToClaudeResponse(chunk, state) {
|
||||
},
|
||||
});
|
||||
|
||||
// Send args as a single JSON delta
|
||||
const argsStr = JSON.stringify(fc.args || {});
|
||||
results.push({
|
||||
type: "content_block_delta",
|
||||
@@ -91,42 +106,32 @@ export function geminiToClaudeResponse(chunk, state) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text content → text block
|
||||
if (part.text !== undefined && part.text !== "" && !hasThoughtSig) {
|
||||
const idx = state.contentBlockIndex++;
|
||||
results.push({
|
||||
type: "content_block_start",
|
||||
index: idx,
|
||||
content_block: { type: "text", text: "" },
|
||||
});
|
||||
results.push({
|
||||
type: "content_block_delta",
|
||||
index: idx,
|
||||
delta: { type: "text_delta", text: part.text },
|
||||
});
|
||||
results.push({ type: "content_block_stop", index: idx });
|
||||
}
|
||||
|
||||
// Text with thoughtSignature but not a thought (model output after thinking)
|
||||
if (
|
||||
// Regular text content → keep text block open across streaming chunks
|
||||
const isRegularText = part.text !== undefined && part.text !== "" && !hasThoughtSig;
|
||||
const isTextAfterThinking =
|
||||
hasThoughtSig &&
|
||||
part.text !== undefined &&
|
||||
part.text !== "" &&
|
||||
!isThought &&
|
||||
!part.functionCall
|
||||
) {
|
||||
const idx = state.contentBlockIndex++;
|
||||
results.push({
|
||||
type: "content_block_start",
|
||||
index: idx,
|
||||
content_block: { type: "text", text: "" },
|
||||
});
|
||||
!part.functionCall;
|
||||
|
||||
if (isRegularText || isTextAfterThinking) {
|
||||
// Open a new text block only if none is open yet
|
||||
if (state.openTextBlockIdx === null) {
|
||||
const idx = state.contentBlockIndex++;
|
||||
state.openTextBlockIdx = idx;
|
||||
results.push({
|
||||
type: "content_block_start",
|
||||
index: idx,
|
||||
content_block: { type: "text", text: "" },
|
||||
});
|
||||
}
|
||||
// Always emit delta into the SAME open block (no open+close per chunk)
|
||||
results.push({
|
||||
type: "content_block_delta",
|
||||
index: idx,
|
||||
index: state.openTextBlockIdx,
|
||||
delta: { type: "text_delta", text: part.text },
|
||||
});
|
||||
results.push({ type: "content_block_stop", index: idx });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,8 +157,14 @@ export function geminiToClaudeResponse(chunk, state) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Finish reason → message_delta + message_stop ───────────────
|
||||
// ── Finish reason → close open blocks + message_delta + message_stop ──
|
||||
if (candidate.finishReason) {
|
||||
// Close any still-open text block before finishing
|
||||
if (state.openTextBlockIdx !== null) {
|
||||
results.push({ type: "content_block_stop", index: state.openTextBlockIdx });
|
||||
state.openTextBlockIdx = null;
|
||||
}
|
||||
|
||||
let stopReason;
|
||||
const reason = candidate.finishReason.toLowerCase();
|
||||
if (state.hasToolUse || reason === "tool_calls") {
|
||||
|
||||
@@ -31,6 +31,8 @@ export default function AgentsPage() {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [addLoading, setAddLoading] = useState(false);
|
||||
const [settings, setSettings] = useState<Record<string, any>>({});
|
||||
const [opencodeConfigLoading, setOpencodeConfigLoading] = useState(false);
|
||||
const [opencodeConfigDone, setOpencodeConfigDone] = useState(false);
|
||||
const [newAgent, setNewAgent] = useState({
|
||||
name: "",
|
||||
binary: "",
|
||||
@@ -303,6 +305,94 @@ export default function AgentsPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* OpenCode Config Generator — shown only when opencode is detected */}
|
||||
{agents.find((a) => a.id === "opencode" && a.installed) && (
|
||||
<Card>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[20px]">code_blocks</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="text-base font-semibold">OpenCode Integration</h3>
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-medium">
|
||||
opencode {agents.find((a) => a.id === "opencode")?.version} detected
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-3">
|
||||
Generate a ready-to-use{" "}
|
||||
<code className="text-xs bg-black/[0.06] dark:bg-white/[0.08] px-1 py-0.5 rounded">
|
||||
opencode.json
|
||||
</code>{" "}
|
||||
with your OmniRoute base URL and all available models — drop it in your project root
|
||||
and run{" "}
|
||||
<code className="text-xs bg-black/[0.06] dark:bg-white/[0.08] px-1 py-0.5 rounded">
|
||||
opencode
|
||||
</code>
|
||||
.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={opencodeConfigLoading}
|
||||
onClick={async () => {
|
||||
setOpencodeConfigLoading(true);
|
||||
setOpencodeConfigDone(false);
|
||||
try {
|
||||
// Fetch available models
|
||||
const modelsRes = await fetch("/v1/models");
|
||||
const modelsData = modelsRes.ok ? await modelsRes.json() : { data: [] };
|
||||
const models: Record<string, { name: string }> = {};
|
||||
for (const m of modelsData.data || []) {
|
||||
models[m.id] = { name: m.id };
|
||||
}
|
||||
// Build opencode.json
|
||||
const baseURL = window.location.origin + "/v1";
|
||||
const config = {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
omniroute: {
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
name: "OmniRoute",
|
||||
options: {
|
||||
baseURL,
|
||||
apiKey: "YOUR_OMNIROUTE_API_KEY",
|
||||
},
|
||||
models:
|
||||
Object.keys(models).length > 0
|
||||
? models
|
||||
: { "gpt-4o": { name: "gpt-4o" } },
|
||||
},
|
||||
},
|
||||
};
|
||||
// Download as file
|
||||
const blob = new Blob([JSON.stringify(config, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "opencode.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
setOpencodeConfigDone(true);
|
||||
setTimeout(() => setOpencodeConfigDone(false), 3000);
|
||||
} catch (err) {
|
||||
console.error("Failed to generate opencode.json:", err);
|
||||
} finally {
|
||||
setOpencodeConfigLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] mr-1">
|
||||
{opencodeConfigDone ? "check" : "download"}
|
||||
</span>
|
||||
{opencodeConfigDone ? "Downloaded!" : "Download opencode.json"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Add Custom Agent */}
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
|
||||
Reference in New Issue
Block a user