Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ _In development — bullets added per PR; finalized at release._
- **fix(translator): strip client-only assistant echo fields on the OpenAI target path (Mistral 422)** — strict OpenAI-compatible upstreams (e.g. `mistral/codestral-latest`) reject client-only assistant "echo" fields sent back as input history with `422 extra_forbidden` (the report hit `messages[].assistant.reasoning_content` via Codex `/responses`). Only `reasoning_content` was being stripped on the OpenAI target path; the sibling echo fields `reasoning`, `refusal`, `annotations` and `cache_control` leaked through and tripped the 422. They are now all dropped on the non-reasoner OpenAI target path. `audio` is deliberately preserved (OpenAI audio models reference a prior assistant audio response by id on multi-turn; Mistral never emits audio, so nothing is lost there). (thanks @xxy9468615)
- **fix(cli): Tailscale login honors `TAILSCALE_AUTHKEY` for non-interactive sign-in** — `startTailscaleLogin` built `tailscale up` without ever reading `process.env.TAILSCALE_AUTHKEY`, so on a pre-authenticated / headless daemon the login waited for an interactive auth URL and timed out (~15s). When `TAILSCALE_AUTHKEY` is set it is now passed via `--auth-key=` (as a spawn argv element — no shell interpolation) so the daemon authenticates non-interactively; when unset, behavior is unchanged. (thanks @ipeterpetrus)
- **fix(dashboard): OAuth modal shows the real error on a non-JSON server response** — the OAuth connect/reauth modal called `await res.json()` unconditionally, so when a build/OAuth endpoint returned a plain-text error (e.g. a `500 Internal Server Error` page) the modal threw `Unexpected token 'I'…` and hid the real failure. Two shared helpers (`parseResponseBody` / `getErrorMessage` in `src/shared/utils/api.ts`) now read the body safely (JSON when it is JSON, raw text otherwise) and surface a clean message either way; all modal fetch sites use them. (thanks @DNNYF)
- **fix(translator): accept AI SDK-style `{ type: "image", image: "data:…" }` content parts** — several OpenAI-input translators only recognized images shaped as `image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style part where `image` is a bare data-URL **string** was silently dropped before reaching a vision provider (OpenCode is one affected client; the gap is generic). The OpenAI→Claude, OpenAI→Kiro and OpenAI→Gemini/Antigravity translators now parse a string `image` data URL into each provider's native image shape (Claude `{source:{type:"base64"}}`, Kiro `images[].source.bytes`, Gemini `inlineData`). (thanks @mugnimaestra)

---

Expand Down
2 changes: 2 additions & 0 deletions config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,10 @@
"_rebaseline_2026_06_18_4228_combo_fallback_bailout": "PR #4228 own growth: combo.ts 2597->2601 (+4 at the existing proactive-fallback applyCompression call in handleComboChat = opt into the TV1 bail-out so a throwing fallback engine is SKIPPED instead of propagating out of executeTarget and being swallowed as a 'Speculative task error' that silently drops the combo target). minGainPercent:0 keeps the advance behavior identical to the default path — this only adds skip-on-throw. The bailout option is threaded through the (non-frozen) strategySelector.ts applyCompression signature down to applyStackedCompression. Cohesive guard at the existing fallback-compression chokepoint; not extractable without hiding the call site.",
"_rebaseline_2026_06_17_stream_recovery": "Stream recovery wiring (free-claude-code port, Fase 4) own growth: chatCore.ts 5898->5980 (+82 at the existing streaming-return chokepoint: read the resolved streamRecovery.enabled setting once; when ON, a reopenStream() thunk re-executes the SAME upstream — it closes over the ~15 per-attempt executor locals (executor/provider/modelToCall/bodyToSend/upstreamStream/execCreds/extendedContext/headers builders/onCredentialsRefreshed/skipUpstreamRetry/contextEditingEnabled) — and the body is wrapped with createRecoverableStream for transparent early-retry; OFF keeps the byte-identical wrapReadableStreamWithFinalize path). All reusable logic (HoldbackBuffer, createRecoverableStream, isRetryableStreamError, hasTerminalMarker) lives in the new open-sse/services/streamRecovery.ts (well under cap). Not extractable without hiding the per-attempt executor-dispatch boundary; opt-in (default OFF). Structural shrink of chatCore.ts tracked in #3501.",
"_rebaseline_2026_06_20_1263_tailscale_authkey": "Re-baseline #1263 (honor TAILSCALE_AUTHKEY for non-interactive tailscale login): tailscaleTunnel.ts 1189->1202 (+13, exported pure tailscaleUpArgs helper + env read + wiring). Cohesive tunnel logic; not extractable.",
"_rebaseline_2026_06_20_1330_ai_sdk_image": "Re-baseline #1330 (accept AI SDK-style {type:image, image:data-URL string} parts): openai-to-kiro.ts 798->807 (+9, new image-part branch mirroring the existing image_url handling), crossing the 800 cap. Freeze at 807. Cohesive translator branch; the other two translators (claude 776, gemini 630) stay under cap.",
"cap": 800,
"frozen": {
"open-sse/translator/request/openai-to-kiro.ts": 807,
"open-sse/config/providerRegistry.ts": 4731,
"open-sse/executors/antigravity.ts": 1680,
"open-sse/executors/base.ts": 1387,
Expand Down
2 changes: 2 additions & 0 deletions open-sse/translator/helpers/geminiHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export function convertOpenAIContentToParts(content: unknown): JsonRecord[] {
// translation layers as an alternative to `rec.image_url` (#2807).
const fileData =
(typeof rec.file_url === "string" ? rec.file_url : undefined) ||
// AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330)
(typeof rec.image === "string" ? rec.image : undefined) ||
imageUrl?.url ||
imageObj?.url ||
fileUrl?.url ||
Expand Down
12 changes: 12 additions & 0 deletions open-sse/translator/request/openai-to-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,18 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
}
} else if (part.type === "image" && part.source) {
blocks.push({ type: "image", source: part.source });
} else if (part.type === "image" && typeof part.image === "string") {
// AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330)
const url = part.image;
const match = url.match(/^data:([^;]+);base64,(.+)$/);
if (match) {
blocks.push({
type: "image",
source: { type: "base64", media_type: match[1], data: match[2] },
});
} else if (url.trim()) {
blocks.push({ type: "image", source: { type: "url", url } });
}
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions open-sse/translator/request/openai-to-kiro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,15 @@ function convertMessages(messages, tools, model) {
const format = (block.source.media_type || "image/jpeg").split("/")[1] || "jpeg";
if (block.source.data)
pendingImages.push({ format, source: { bytes: block.source.data } });
} else if (block.type === "image" && typeof block.image === "string") {
// AI SDK-style image part: { type: "image", image: "data:...;base64,..." } (#1330)
const url = block.image;
if (url.startsWith("data:")) {
const [header, bytes] = url.split(",", 2);
const mediaType = header.split(";")[0].replace("data:", "");
const format = mediaType.split("/")[1] || "jpeg";
if (bytes) pendingImages.push({ format, source: { bytes } });
}
}
}

Expand Down
61 changes: 61 additions & 0 deletions tests/unit/translator-ai-sdk-image-parts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";

// Regression for port-from-9router#1330: AI SDK-style image content parts shaped as
// `{ type: "image", image: "data:image/png;base64,..." }` (a STRING, not an object
// with `.url`/`.source`) were dropped by the OpenAI-input translators, so any client
// sending images this way (e.g. OpenCode) lost them before reaching a vision provider.
const gemini = await import("../../open-sse/translator/helpers/geminiHelper.ts");
const claude = await import("../../open-sse/translator/request/openai-to-claude.ts");
const kiro = await import("../../open-sse/translator/request/openai-to-kiro.ts");

const DATA_URL = "data:image/png;base64,AAAABBBB";

test("#1330: Gemini maps AI SDK image string to inlineData", () => {
const parts = gemini.convertOpenAIContentToParts([
{ type: "text", text: "describe" },
{ type: "image", image: DATA_URL },
]);
const inline = parts.find((p) => (p as { inlineData?: unknown }).inlineData);
assert.ok(inline, "expected an inlineData part");
assert.deepEqual((inline as { inlineData: unknown }).inlineData, {
mimeType: "image/png",
data: "AAAABBBB",
});
});

test("#1330: Claude maps AI SDK image string to a base64 image source block", () => {
const out = claude.openaiToClaudeRequest(
"claude-sonnet-4-6",
{
messages: [
{ role: "user", content: [{ type: "image", image: DATA_URL }] },
],
},
false
) as { messages: { content: { type: string; source?: Record<string, unknown> }[] }[] };

const block = out.messages[0].content.find((b) => b.type === "image");
assert.ok(block, "expected an image block");
assert.deepEqual(block!.source, {
type: "base64",
media_type: "image/png",
data: "AAAABBBB",
});
});

test("#1330: Kiro maps AI SDK image string to userInputMessage.images bytes", () => {
const payload = kiro.buildKiroPayload(
"kr/claude-sonnet-4.6",
{
messages: [
{ role: "user", content: [{ type: "text", text: "describe" }, { type: "image", image: DATA_URL }] },
],
},
false,
{}
);
const serialized = JSON.stringify(payload);
assert.ok(serialized.includes('"AAAABBBB"'), "expected the image bytes in the Kiro payload");
assert.ok(serialized.includes('"png"'), "expected the png format in the Kiro payload");
});
Loading