Skip to content

fix: port upstream Lote 2 — database safety, stream and translator robustness - #5

Merged
filipeforattini merged 33 commits into
mainfrom
upstream-lote2
Sep 18, 2026
Merged

filipeforattini merged 33 commits into
mainfrom
upstream-lote2

Conversation

@filipeforattini

@filipeforattini filipeforattini commented Sep 18, 2026 •

Copy link
Copy Markdown

Cherry-picks ~20 upstream PRs (decolua/9router decolua#3862, decolua#3822, decolua#3997, decolua#3657, decolua#3772, decolua#3779, decolua#3826, decolua#3878, decolua#3885, decolua#3886, decolua#3936, decolua#3980, decolua#4002, decolua#4050, decolua#4058, decolua#4063, decolua#4069, decolua#4079, decolua#4130, decolua#4133):

  • Database safety: fail closed before any schema mutation on a corrupted SQLite database; prevent silent database wipe with startup quick_check
  • OAuth: parse numeric epoch expiresAt so imported connections still refresh
  • Streams: terminate streams that end without a finish_reason; Ollama NDJSON no longer blocked as non-SSE (VS Code chat fixed); commandcode retries transient stream errors instead of emitting fake stop chunks
  • Translator: deduplicate/repair repeated tool call arguments; recover tool results without a call id; keep Responses tool-output images as images; stop emitting literal think tags on Claude→OpenAI; preserve optional tool parameters and function-tool strict across Responses/Codex; repair trailing assistant prefill; placeholder for binary tool_result blobs; emit max_completion_tokens for gpt-5/o-series; strip output_config.format for Claude-compatible gateways; decloak tool names on claude→claude and same-format OAuth streams
  • Claude: drop the diagnostics body field rejected by Anthropic

83 tests passing (0 failed); eslint 0 errors on 81 touched js files.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

Jordannst and others added 30 commits September 18, 2026 00:10
…ll refresh

parseTimeMs() accepted numbers and date-like strings, but a numeric epoch in
string form ("1789012345678") went down the Date branch, where it is an
Invalid Date, and returned null.

A null expiry silently disables both proactive refresh paths:
  - shouldRefreshCredentials() returns false, so the on-request refresh in
    checkAndRefreshToken() never fires;
  - selectConnectionsNeedingRefresh() skips the connection, so the background
    sweep never fires either.

The connection then keeps an expired access token and 401s until the user
re-authenticates by hand - the same symptom as decolua#2546, reached through a
different input shape.

Numeric epochs reach the DB unnormalized through the bulk-import routes, which
persist the user-supplied value verbatim:
  src/app/api/oauth/grok-cli/bulk-import/route.js:77  (raw.expires_at)
  src/app/api/oauth/codex/bulk-import/route.js:97     (item.expiresAt)
compare src/lib/oauth/kiroExternalIdp.js:57, which already normalizes to ISO.

parseTimeMs() now converts numeric strings with the same seconds/ms heuristic
it already applies to numbers, and is exported so normalizeExpiresAt() in
src/sse/services/tokenRefresh.js reuses it - a numeric epoch that is already
stored now self-heals to ISO on the next refresh.

(cherry picked from commit ac8021e)
…openai request builders

OpenAI's Chat Completions API rejects max_tokens for gpt-5.x and
hyphenated o-series models with 400 unsupported_parameter ("Use
'max_completion_tokens' instead"). The claude-to-openai,
gemini-to-openai and antigravity-to-openai request builders emitted
max_tokens unconditionally, so translated requests routed to the native
openai api-key provider failed at the provider for those models. The
GitHub executor already maps the param (requiresMaxCompletionTokens);
hoist the same match next to adjustMaxTokens and apply it in all three
builders so requests are born with the right param for every executor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit afd435f)
…l calls stay separate

(cherry picked from commit 598bcb3)
…in Claude streaming

Prevent corrupted JSON payloads when upstream proxies or models (e.g. ClinePass, GLM)
repeat tool call arguments on finish or stream cumulatively.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 2292579)
Guard tool calls so each block emits input_json_delta and content_block_stop
at most once per stream, avoiding doubled JSON arguments when upstream
proxies send multiple finish_reason chunks.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 628c3a2)
…rd late usage

Addresses Copilot review on PR decolua#3779:
- Extract repairDuplicatedJsonArguments/appendToolArgs into
  translator/concerns/toolArgs.js shared by both Claude response
  translators (removes duplication drift risk).
- sanitizeToolArgs: single JSON.parse fast path; repair only on failure.
- Later finish chunk with usage now emits a usage-only message_delta
  (no duplicate tool blocks, no second message_stop) so clients still
  receive final usage.

Co-Authored-By: Claude Code <noreply@anthropic.com>
(cherry picked from commit 0e06339)
The previous decloak fix put decloakStreamChunk() into translateResponse()'s
same-format branch, but the runtime never called it there:

- Streaming: buildTransformStream() routes same-format requests to the raw
  passthrough stream, which forwards bytes without ever consulting
  translateResponse — so OAuth-cloaked tool names (*_ide) still leaked to
  claude-format clients and every tool call was rejected as unknown.
- Non-streaming: claude-format requests omitting `stream` were treated as
  streaming (`body.stream !== false`), so the upstream's complete-message
  JSON body came back through the passthrough stream with a spurious
  trailing "data: [DONE]" — also skipping decloak.

Two changes:

1. buildTransformStream(): route same-format streams through the translate
   stream whenever a toolNameMap is present, so the same-format decloak
   branch actually runs. Without a map, passthrough stays byte-exact.

2. chatCore: for claude-format clients, default `stream` per Anthropic
   Messages API semantics (omitted = false), routing those requests to
   handleNonStreamingResponse, which already decloaks and returns clean
   JSON.

handleForcedSSEToJson needs no change: every forceStream provider lacks the
cloakToolsOnOAuth quirk, so cloaked routes can never reach it.

(cherry picked from commit 41e0310)
A Responses target received the Chat-Completions-only top-level
reasoning_effort instead of the nested reasoning:{effort} it expects, so
thinking was silently dropped.

nestReasoningEffort runs at the end of applyThinking and rewrites the key
for both spellings of the target — FORMAT_TO_NATIVE carries "openai-responses"
and "openai-response", and usageTracking/modality dispatch on the singular
one. Sibling Responses-only keys (summary, mode) are preserved.

chatCore's codex native-passthrough branch is pinned to FORMATS.OPENAI: it
reads the flat key off a scratch object and nests it itself, so handing it a
nested body would silently stop (ultra)/(max) suffixes from applying.

(cherry picked from commit 1b042a1)
…port replies

Providers on a binary or proprietary transport — kiro EventStream, cursor
protobuf, commandcode NDJSON — decode their upstream reply to OpenAI Chat
Completions inside their own executor. translateNonStreamingResponse has no
branch for those target formats, so it fell through and returned the raw
chat.completion body regardless of what the client asked for: a stream:false
request to /v1/messages handed a Claude client `object:"chat.completion"`, and
LiteLLM's anthropic/ prefix died on KeyError: 'content'. The streaming path was
already correct.

Convert on the way out when the body carries a choices array and the client
speaks Claude or Responses.

Fixes decolua#3199

(cherry picked from commit 0f23a48)
filterToOpenAIFormat took two shortcuts for assistant messages carrying
tool_calls: skip content normalisation, and never drop the message. Both
were gated on plain truthiness, and an empty array is truthy.

An assistant turn arriving as tool_calls: [] therefore kept its Claude-only
blocks (thinking, redacted_thinking) instead of having them stripped, and
survived the empty-message filter with blank content. Upstreams that reject
either shape rejected the whole request.

Switches both guards to tool_calls?.length, matching claude-to-openai.js
and the kiro executor. Messages with real tool calls are unaffected.

(cherry picked from commit 8805156)
…olua#3492)

chatCore resolved the response framing with:

    let stream = providerRequiresStreaming ? true : (body.stream !== false);

The OpenAI API defines stream with a default of false, so a body that
omits the key is a non-streaming request. `!== false` reads an absent key
as "stream", and the handler took the SSE branch for a payload it had
already built as a complete chat.completion object: text/event-stream on
the wire, plus a data: [DONE] concatenated onto valid JSON. Strict parsers
reject that, so every non-streaming call from an AI-SDK-based client fails
while interactive streaming clients look healthy.

The correct predicate was already sitting on the line above, computed and
used further down for the forced-stream-to-JSON branch. Move it into
chatCore/streamMode.js, keep the Gemini and Antigravity carve-out (their
endpoints carry the mode in the path, and only the streaming ones are
routed here), and read the mode from it.

The Accept: application/json branch below stays: it only ever forces
non-streaming, so it is now a no-op for this case rather than the only
thing that rescued it - and a client that sends no Accept header, like
the curl reproduction in the issue, was never rescued at all.

(cherry picked from commit 6533939)
Every content-emitting branch in this translator opens with
`role: "assistant"` when chunkIndex is 0 and then advances chunkIndex. The
error branch was the one that did neither: it pushed a content delta with no
role and left chunkIndex where it was.

An upstream error is routinely the FIRST event on the stream -- a 503 arrives
before any token -- so the only delta a client received for a failed turn had
no role on it. And because chunkIndex never advanced, text arriving after the
error still believed it was the opening chunk and sent a second role delta.

Same shape as the text-delta branch beside it. The message text is unchanged.

(cherry picked from commit d43850f)
The decolua#3492 tests exercised clientRequestedStreaming in isolation, so
reverting the call site in chatCore.js left the file green — the wiring
was unobserved. handleChatCore takes ~30 collaborators and cannot be
driven from a unit test, so assert the call site at the source level.

Reverting chatCore.js now fails 3 of the 10 cases.

(cherry picked from commit 18e6751)
Claude Code clients newer than the pinned CLAUDE_CLI_VERSION send a
top-level `diagnostics` telemetry field. The claude provider forwards it
verbatim and Anthropic rejects the request with

  400 invalid_request_error: "diagnostics: Extra inputs are not permitted"

because the field is gated behind a beta the provider's Anthropic-Beta
header does not advertise.

The 400 is then escalated: every account is locked for 30s and the
fallback chain is exhausted, so an active session keeps re-locking the
pool and never recovers, while an idle one appears to work.

Add the field to the existing config-driven strip rules next to the
`temperature` rule (decolua#1748), matching this file's stated convention of
adding a rule rather than scattering `delete body.x` across executors.
`base.js` calls transformRequest before every fetch and claude has no
dedicated executor, so DefaultExecutor -> stripUnsupportedParams covers
the passthrough path.

(cherry picked from commit 188ff29)
translateRequest() cloaks client tools (CLAUDE_TOOL_SUFFIX) for providers with
the cloakToolsOnOAuth quirk whenever an sk-ant-oat token is used, including
requests that need no format conversion — the cloak block runs after the
same-format shortcut.

buildTransformStream() only routed through the translate pipeline when
needsTranslation() was true, so a claude -> claude stream (e.g. an Anthropic
Messages client proxied to a Claude OAuth account) fell back to the raw
passthrough stream, which takes no toolNameMap and never calls
translateResponse(). The client therefore received the cloaked, _ide-suffixed
tool name in content_block_start and rejected the call as an unknown tool:

  Model tried to call unavailable tool 'bash_ide'

Non-streaming responses were unaffected: handleNonStreamingResponse() applies
decloakToolNames() unconditionally.

Route same-format streams through the translate pipeline when a toolNameMap is
present; its sourceFormat === targetFormat branch applies decloakStreamChunk()
and otherwise relays the parsed event untouched.

(cherry picked from commit 482f885)
…decolua#2302)

Vertex-backed Claude routes reject a conversation whose last message is an
assistant turn:

  400 invalid_request_error
  "This model does not support assistant message prefill.
   The conversation must end with a user message."

Agentic clients (Claude Code, Cowork) legitimately produce that shape when
resuming, so the request needs repairing rather than refusing.

Unconditionally popping trailing assistant turns restores the invariant but is
lossy in three cases:

  - an unresolved tool_use loses its pairing, so the next request is
    structurally invalid;
  - provider-signed thinking is history the client cannot regenerate;
  - redacted_thinking is an opaque blob that is gone once dropped.

applyAssistantPrefillPolicy restores the terminal-user invariant while keeping
what carries meaning: unresolved tool_use blocks are closed with matching error
tool_results, signed/redacted reasoning and server_tool_use turns are kept and
given a user boundary, and only genuinely empty or unsigned-thinking turns are
removed. Unsigned thinking is not preserved because Anthropic rejects it and the
existing cleanup passes already discard it.

Applied on the Antigravity request path, where the rejection was reported.

Adds 13 unit tests covering each branch, multi-tool pairing, string content, and
the invariant that output always ends with a user turn.

(cherry picked from commit 5793cec)
The policy inspected the tail exactly once, so dropping a contentless
trailing assistant could expose another assistant turn underneath and the
request still reached Anthropic ending in an assistant message:

  400 invalid_request_error
  "This model does not support assistant message prefill."

Consecutive assistant turns do reach this pass in practice (an interrupted
turn followed by an empty one). A body whose only turn was a contentless
assistant also became messages: [], which callers then dereference.

Re-check the invariant after every drop, and leave a minimal user turn when
the whole conversation was contentless prefill.

Regression tests: 6 added (stacked drop over text, over an interrupted
tool_use, and the never-empty case).

(cherry picked from commit 6142867)
The guard added to block HTML error pages (Cloudflare 5xx) rejected any
content-type that was not text/event-stream or application/json. Ollama
streams application/x-ndjson, so every ollama-local request was blocked
before reaching the transform and surfaced as an opaque "Provider error"
with no status, which then locked the account for 30s.

The SSE transform already parses Ollama NDJSON (see ollama-stream-tail
test), so the body was always safe to pipe; only the guard disagreed.

(cherry picked from commit 99f0ce5)
The guard that keeps an HTML error page out of the SSE pipe compared the
upstream content type against text/event-stream and application/json
alone. An ollama-local provider dials Ollama's native /api/chat
(providers/registry/ollama-local.js) and gets application/x-ndjson on a
SUCCESSFUL stream, so every Ollama request came back as
'BLOCKED 200 · ollama-local/<model> · non-SSE (application/x-ndjson)'
and the client saw a 503 - while
translator/response/ollama-to-openai.js exists to convert exactly that
stream and tests/unit/ollama-stream-tail.js already covers it.

The guard now consults the format the provider declares: a content type
an upstream format legitimately streams is not an error page. Scoped to
that format, so NDJSON from a provider declared openai stays refused,
which is the right answer since nothing would translate it.

Fixes decolua#3985

(cherry picked from commit 6ad36f8)
…eways

Claude Code 2.1.270 sends output_config.format (json_schema) on its
session-title request. Claude-compatible gateways (reproduced live on
Alibaba MaaS /apps/anthropic with deepseek-v4.1-flash) reject it with
400 'This response_format type is unavailable now', which locks the
connection 30s and fails every subsequent request until reset.

Structured output format is an official-Anthropic-only feature, so
prepareClaudeRequest now drops output_config.format for non-anthropic
providers while keeping effort (and minimax's whole-field
dropOutputConfig quirk keeps precedence).

(cherry picked from commit 0536a11)
…argets

The translated path strips output_config.format in prepareClaudeRequest,
but the claude (passthrough) branch bypasses translation entirely — so
Claude Code title-gen json_schema requests still 400'd on
Claude-compatible gateways (Alibaba MaaS) and locked the account.
Mirror the strip in the passthrough branch, keyed on provider !== anthropic.

(cherry picked from commit ad96323)
Chat `tool` messages only accept a string, so openaiResponsesToOpenAIRequest()
JSON.stringify()'d an array `function_call_output.output` — the shape Codex uses
for screenshots returned by image tools. Every screenshot then travelled as a few
hundred KB of base64 text, and upstream tokenizers bill that as text (~1.5
chars/token): two screenshots alone added ~500K tokens, pushing a 1M-context
request over its limit with HTTP 400 "maximum context length".

Keep the text parts in the tool message and re-attach the images as a following
user turn, which is how vision-capable chat upstreams expect them. Arrays that
carry no content parts keep the previous JSON.stringify shape.

Also declare the DeepSeek API's Flash models as vision models: deepseek-flash
accepts images (the retired deepseek-v4-flash / -vision-exp ids are served by the
same V4.1-Flash model), yet getCapabilitiesForModel("deepseek",
"deepseek-v4-flash") resolved to vision:false, so a client-supplied image was
replaced with "[image omitted: model has no vision support]". v4-pro stays
text-only, matching DeepSeek's own table.

Tests: tests/unit/responses-tool-output-images.test.js
(cherry picked from commit acfc661)
claude-to-openai emitted "<think>" / "</think>" as delta.content around
thinking blocks, while the thinking text itself goes out as
delta.reasoning_content. Two problems follow:

- Clients that read reasoning_content render the tags as visible text.
  opencode shows a literal "<think></think>" in the reply; its stripper only
  fires when a single chunk starts with the tag, and here they arrive as
  separate deltas.
- Anthropic adaptive thinking (Opus/Sonnet 4.6+) opens and closes a thinking
  block without necessarily sending a thinking_delta, so the tags often wrap
  nothing at all — visible on every request at reasoning_effort >= low.

reasoning_content alone is the convention the other *-to-openai translators
follow (gemini, kiro, ollama) and the shape openai-to-claude reads back via
extractReasoningText(), so dropping the tags loses nothing.

The existing golden snapshot captured the buggy output and is updated; two
regression tests cover the empty-thinking-block case and the
reasoning_content contract.

(cherry picked from commit 2c7e145)
…nslation

Default translated Chat Completions function tools to strict:false without
rewriting their parameters or required arrays. Preserve strict through
Codex tool normalization, which previously discarded even explicit values.
Keep native Responses defaults and explicit strict:true requests intact.

Add 26 regression cases for optional sessionID, absent/empty/nested required,
strict handling, normalization idempotence, and streamed/replayed arguments.
Live OpenCode subagent acceptance remains to be verified.

(cherry picked from commit e0b4e42)
…ad failure

The peek loop broke out of its chunk as soon as it saw the first event that ends
inspection. Upstream can deliver a whole trailing run (start, start-step,
text-*, finish-step, finish) in one network chunk, so that break discarded the
finish/finish-step events the downstream needs to produce finish_reason — the
reply arrived with no way to tell the turn had ended.

The read-failure fallback was dead on arrival. It returned the original
Response, but the reader had already disturbed its body, so any mid-stream reset
threw "Body is unusable: Body has already been read" and dropped every buffered
line. Record the failure and continue from an EOF reader instead, so buffered
events still reach the client. That also restores the detectedError check the
old early return skipped.

(cherry picked from commit b1a51b1)
…log tool calls

An OpenAI client ends a turn deliberately only when it receives a chunk with a
truthy finish_reason. pi requires it, so a stream that ends without one aborts
the request with "Stream ended without finish_reason", which hides whether the
reply was truncated or simply empty.

Four ways a stream reached a client with no terminal frame:

- The passthrough flush emitted its buffered tail verbatim and then appended
  "data: [DONE]\n\n" with no separator. SSE events dispatch only on a blank
  line, so the two collapsed into one unparsable frame and the finish_reason the
  tail carried was lost. Terminate the event, and drop a tail cut mid-payload
  instead of forwarding unparseable garbage.
- Nothing synthesized a terminal when the upstream never sent one. Emit
  finish_reason "network_error" so the client closes the turn as a deliberate
  error, then [DONE]. Only for OpenAI clients that saw no terminal: healthy
  streams keep their native terminal, and gemini-family and other source formats
  are untouched.
- A translator's flush() is a no-op when the upstream never sent its terminal
  event, so the same synthesis runs there too.
- A mid-stream transport error makes the writable side error, so flush() never
  runs. pipeWithDisconnect already had an onAbortTerminal hook for this, but it
  was wired for Responses clients only. The transform stream now exposes the
  OpenAI equivalent, returning null once a terminal has gone out so a healthy
  stream never gains a second one.

Reproducing this showed the request log could not tell a truncated stream from an
ordinary tool call: only content and thinking were accumulated, so a turn that
called a tool and produced no assistant text was recorded as "[Empty streaming
response]". Accumulate tool calls in both the OpenAI and Claude shapes, record
them under tool_calls, and label the content accordingly.

(cherry picked from commit ea55b48)
sriramr-repo and others added 3 commits September 18, 2026 00:32
)

The decolua#4133 cherry-pick committed the file with unresolved markers: our
isStreamableUpstreamContentType helper (from decolua#4002) already covers
application/x-ndjson for Ollama, and our log.errorLine logging supersedes
the upstream plain console.warn.
@filipeforattini
filipeforattini merged commit f406c42 into main Sep 18, 2026
1 check passed
@filipeforattini
filipeforattini deleted the upstream-lote2 branch September 21, 2026 03:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.