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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ _In development — bullets added per PR; finalized at release._

### 🔧 Bug Fixes

- **opencode/observability:** make OpenCode Free account/proxy rotation visible and fix two real defects surfaced alongside it. **(1)** the per-request rotation selection log (`dispatch via account … through proxy …`) was `debug` (hidden at default `APP_LOG_LEVEL=info`) — promoted to `info` so the shuffle/cooldown lifecycle is auditable (token stays masked). **(2)** `[ProxyEgress]` reported `proxy=direct` even when an account proxy was applied, because the egress logger ran outside the executor's nested proxy context — the effective applied proxy is now captured (via an applied-proxy sink threaded through the proxy AsyncLocalStorage) and reflected in the egress log. **(3)** `[callLogs] too many SQL variables` — `deleteCallLogRowsByIds` deleted up to 5000 ids in one `IN (…)`, exceeding SQLite's ~999 bound-param cap and aborting log trimming/retention; ids are now chunked (≤500 per statement). Regression guards: `tests/unit/call-log-trim-sql-vars-5217.test.ts`, `apply-executor-proxy-info-5217.test.ts`, extended `opencode-proxy-rotation-4954.test.ts`. The Proxy Pool dropdown (by-id) UI (Gap 1) is a follow-up requiring browser validation. ([#5217](https://github.com/diegosouzapw/OmniRoute/issues/5217) — thanks @daniij)
- **chatgpt-web:** wire tool/function calling into the `chatgpt-web` provider. It was the only web-session executor that never read `body.tools` — both response builders hardcoded `finish_reason:"stop"` and emitted only content, so tool calls were silently dropped (the model answered in prose). It now uses the shared `webTools` prompt-emulation shim (a `<tool>`-contract system message + `<tool>{…}</tool>` response parsing) exactly like its 9 sibling executors (qwen-web, perplexity-web, …) — it was simply omitted from the #3259 rollout. Tool mode buffers and emits `tool_calls` + `finish_reason:"tool_calls"` (gated off the image-gen path); plain chat is unchanged. Regression guard: `tests/unit/chatgpt-web-tools-5240.test.ts`. ([#5240](https://github.com/diegosouzapw/OmniRoute/issues/5240) — thanks @Rougler)

### 🔧 Bug Fixes

Expand Down
54 changes: 30 additions & 24 deletions open-sse/executors/chatgpt-web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

import { BaseExecutor, type ExecuteInput, type ProviderCredentials } from "./base.ts";
import { describeChatGptWebHttpError } from "./chatgptWebErrors.ts";
import { prepareToolMessages } from "../translator/webTools.ts";
import { buildToolModeResponse } from "./chatgptWebTools.ts";
import { createHash, randomUUID, randomBytes } from "node:crypto";
import {
tlsFetchChatGpt,
Expand Down Expand Up @@ -1560,28 +1562,15 @@ function buildStreamingResponse(
}
}

// If the assistant kicked off the async image_gen tool, the SSE
// stream ends with a "Processing image..." placeholder. Poll the
// conversation endpoint in the background for the final pointer.
// We only kick polling off if the in-stream pointers are empty —
// sometimes the synchronous path also fires and we already have one.
// Heartbeat helper: while we wait on long-running async work
// (WebSocket for image-gen, /files/download → 2-3 MB image fetch),
// the SSE stream goes quiet and Open WebUI's HTTP client times out
// at ~30s. We saw this in production: `disconnect: ResponseAborted`
// followed by "Controller is already closed".
//
// Layered traps to avoid:
// - SSE comments (`: ...`) are silently ignored by aiohttp's
// read-activity tracker.
// - Empty `delta:{}` chunks ARE emitted by us but get filtered
// out upstream by `hasValuableContent` in
// `open-sse/utils/streamHelpers.ts` (it requires content,
// role, or finish_reason on OpenAI chunks).
//
// So heartbeats are zero-width-space content deltas (`"​"`):
// they pass the valuable-content filter (non-empty content), reach
// the client as data events, and render as nothing visible.
// Async image_gen ends the SSE with a "Processing image..."
// placeholder; poll the conversation endpoint in the background for
// the final pointer (only when in-stream pointers are empty).
// Heartbeat: long async work (WebSocket image-gen, 2-3 MB image
// fetch) leaves the SSE quiet and Open WebUI times out at ~30s
// (`disconnect: ResponseAborted`). SSE comments and empty `delta:{}`
// chunks are both filtered upstream (`hasValuableContent` in
// open-sse/utils/streamHelpers.ts), so heartbeats are zero-width-space
// content deltas (`"​"`): they pass the filter and render invisibly.
const startHeartbeat = (intervalMs = 5_000): (() => void) => {
const heartbeatChunk = sseChunk({
id: cid,
Expand Down Expand Up @@ -2467,6 +2456,13 @@ export class ChatGptWebExecutor extends BaseExecutor {
};
}

// Tool-call emulation (#5240): inject a `<tool>` contract when `tools` are
// present; parsed back on the response side. Mirrors qwen-web/perplexity-web.
const { hasTools, requestedTools, effectiveMessages } = prepareToolMessages(
(body || {}) as Record<string, unknown>,
messages as Array<{ role: string; content: unknown }>
);

if (!credentials.apiKey) {
return {
response: errorResponse(
Expand Down Expand Up @@ -2654,7 +2650,7 @@ export class ChatGptWebExecutor extends BaseExecutor {
}

// 4. Build conversation request
const parsed = parseOpenAIMessages(messages);
const parsed = parseOpenAIMessages(effectiveMessages);
if (!parsed.currentMsg.trim() && parsed.history.length === 0) {
return {
response: errorResponse(400, "Empty user message"),
Expand Down Expand Up @@ -2790,8 +2786,11 @@ export class ChatGptWebExecutor extends BaseExecutor {
const pollAsyncImage = (conversationId: string) =>
pollForAsyncImage(conversationId, resolverCtx);

// Tool mode buffers (no live streaming) and is gated off the image-gen path.
const toolMode = hasTools && !forImageGen;

let finalResponse: Response;
if (stream) {
if (stream && !toolMode) {
const sseStream = buildStreamingResponse(
bodyStream,
model,
Expand Down Expand Up @@ -2822,6 +2821,13 @@ export class ChatGptWebExecutor extends BaseExecutor {
log,
signal
);
if (toolMode) {
finalResponse = await buildToolModeResponse(finalResponse, requestedTools, stream, {
cid,
created,
model,
});
}
}

return { response: finalResponse, url: CONV_URL, headers, transformedBody: cgptBody };
Expand Down
119 changes: 119 additions & 0 deletions open-sse/executors/chatgptWebTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Tool-call emulation helpers for the ChatGPT Web executor (#5240).
//
// chatgpt.com has no native function calling. When the OpenAI request carries
// `tools`, the prompt-side shim (`prepareToolMessages` in
// ../translator/webTools.ts) injects a `<tool>` contract; on the response side
// we parse `<tool>{...}</tool>` blocks back into OpenAI `tool_calls` —
// mirroring the sibling web-session executors (qwen-web, perplexity-web, ...).
//
// The whole tool-mode orchestration lives here so the (frozen) chatgpt-web.ts
// only gains an import + a single delegating call.

import { buildToolAwareResult } from "../translator/webTools.ts";

const SSE_HEADERS: Record<string, string> = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
};

function sseChunk(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}

/**
* Parse any `<tool>` blocks in a buffered JSON completion's assistant content
* into OpenAI tool_calls and rewrite the choice. On parse failure the original
* body passes through untouched.
*/
async function applyToolCallsToJsonResponse(
response: Response,
requestedTools: unknown
): Promise<Response> {
const bodyText = await response.text();
try {
const json = JSON.parse(bodyText);
const rawContent = json?.choices?.[0]?.message?.content || "";
const { content, toolCalls, finishReason } = buildToolAwareResult(
rawContent,
requestedTools,
"cgpt"
);
if (toolCalls) {
json.choices[0].message = { role: "assistant", content: null, tool_calls: toolCalls };
json.choices[0].finish_reason = finishReason;
} else {
json.choices[0].message.content = content;
}
return new Response(JSON.stringify(json), {
status: response.status,
headers: { "Content-Type": "application/json" },
});
} catch {
return new Response(bodyText, {
status: response.status,
headers: { "Content-Type": "application/json" },
});
}
}

/**
* Replay an already-built OpenAI `chat.completion` object as a buffered SSE
* stream: a role chunk, then a single terminal chunk carrying either
* `delta.tool_calls` + `finish_reason: "tool_calls"` or plain content +
* `finish_reason: "stop"`. No token-by-token streaming while tools are active.
*/
function toolCompletionToSseStream(
completion: Record<string, unknown>,
cid: string,
created: number,
model: string
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
const choice = (completion?.choices as Array<Record<string, unknown>> | undefined)?.[0] ?? {};
const message = (choice.message as Record<string, unknown>) ?? {};
const finishReason = (choice.finish_reason as string) ?? "stop";
const chunk = (delta: Record<string, unknown>, fr: string | null): Uint8Array =>
encoder.encode(
sseChunk({
id: cid,
object: "chat.completion.chunk",
created,
model,
system_fingerprint: null,
choices: [{ index: 0, delta, finish_reason: fr, logprobs: null }],
})
);

return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(chunk({ role: "assistant" }, null));
const delta = message.tool_calls
? { tool_calls: message.tool_calls }
: { content: (message.content as string) ?? "" };
controller.enqueue(chunk(delta, finishReason));
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
}

/**
* Tool mode: parse `<tool>` blocks in an already-buffered JSON completion into
* tool_calls, then return either the JSON completion (non-streaming) or a
* terminal SSE replay of it (streaming).
*/
export async function buildToolModeResponse(
bufferedJson: Response,
requestedTools: unknown,
stream: boolean,
meta: { cid: string; created: number; model: string }
): Promise<Response> {
const jsonResponse = await applyToolCallsToJsonResponse(bufferedJson, requestedTools);
if (!stream) return jsonResponse;
const completion = await jsonResponse.json();
return new Response(toolCompletionToSseStream(completion, meta.cid, meta.created, meta.model), {
status: 200,
headers: SSE_HEADERS,
});
}
Loading
Loading