From 4474f373ac850cb0fb576930003e162b09b5562b Mon Sep 17 00:00:00 2001 From: greenpolo Date: Tue, 29 Sep 2026 16:35:24 -0400 Subject: [PATCH 1/2] Stop the gateway from altering native Claude Code behavior Claude requests now reach Anthropic as Claude Code sent them, and Anthropic's answer or failure reaches Claude Code as it would natively: - Accept requests up to Anthropic's 32 MB limit (was 8 MiB) and answer an oversized one with Anthropic's own request_too_large error. - Pass Claude streams through unchanged; tool observation stops quietly when it cannot parse instead of injecting a stream error. - Drop the connection on an Anthropic failure so Claude Code retries, instead of a rebranded "Native gateway" error. - Forward every non-Messages /v1/* route (e.g. /v1/files) as raw bytes. - Stop overriding API_TIMEOUT_MS; provider streams already send headers and pings early. - Replace the per-tool-call PreToolUse command hook with the Mods classic.PreToolUse handler, posting only for multi/ models. - Apply the session-identity check only to provider routes. - Skip gateway calls for native Claude subagent spawns. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- CHANGELOG.md | 34 +++++ plugins/multi-core/hooks/register.ts | 2 +- .../multi-core/hooks/tests/workers.test.ts | 95 ++++++++++++ plugins/multi-core/hooks/usage.ts | 50 +++++- plugins/multi-core/hooks/workers.ts | 39 +++++ plugins/multi-core/src/gateway/fetch.ts | 4 +- plugins/multi-core/src/gateway/server.ts | 116 ++++++++++++-- .../multi-core/src/gateway/tool-observer.ts | 91 ++++++++--- plugins/multi-core/src/launcher.ts | 15 -- .../unit/native-anthropic-passthrough.test.ts | 142 ++++++++++++++++++ test/unit/native-gateway.test.ts | 17 ++- test/unit/native-launcher.test.ts | 12 +- test/unit/native-reviewer-discovery.test.ts | 2 +- test/unit/native-usage-menu.test.ts | 2 +- 14 files changed, 556 insertions(+), 65 deletions(-) create mode 100644 test/unit/native-anthropic-passthrough.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9d055..ff8dff3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,40 @@ Entries record changes when they were made, including superseded decisions. See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and [README.md](README.md) for current capabilities. +## Unreleased + +- **Stop the gateway from altering native Claude requests.** Claude requests now + reach Anthropic as Claude Code sent them, and Anthropic's answer or failure + reaches Claude Code as it would natively. A tool call cut off by `max_tokens` + no longer ends the stream with an injected "Unexpected end of JSON input" + error: tool observation is best effort and stops quietly when it cannot parse, + while the stream passes through unchanged and uncapped. A connection failure + now drops the connection (so Claude Code's own retry runs) instead of + becoming a "Native gateway: fetch failed" 502 or an injected stream error. + Every `/v1/*` request other than Messages, such as `/v1/files` and + `/v1/files/{id}/content`, is forwarded to Anthropic as raw bytes with the + caller's own headers; it no longer needs the gateway token, which Claude Code + does not send to those routes, and adds no credential of its own. +- **Keep Claude's native request timeout and retries.** Multi set `API_TIMEOUT_MS` + to its maximum so long native runs would not time out, which also meant a stuck + Anthropic connection never timed out or retried. Claude Code's timer only bounds + the wait for response headers, and every streamed provider reply already sends its + headers and periodic `ping` events while it works, so Multi no longer sets it. An + `API_TIMEOUT_MS` you set is still respected. +- **Stop running a command on every Claude tool call.** Multi registered a + `PreToolUse` command hook that started Node for each tool call, including + Claude's own, only to tell the gateway which provider proposed an action. That + attribution now runs inside Claude Mods, and only for provider models. +- **Never refuse a Claude request over session identity.** A Claude request whose + session header and metadata disagreed was rejected with a 400. The check now + applies only to provider models, where session identity matters. +- **Skip gateway calls when Claude starts its own subagents.** Every native Claude + subagent triggered two gateway lookups. A subagent the gateway already classified + as Claude's, in a Claude session, now starts without any. +- **Accept requests up to Claude's own 32 MB limit.** The gateway refused any + request over 8 MiB, so a Claude conversation with a few screenshots or PDFs + failed with "Request too large (max 32MB)" well before Anthropic's limit. It + now allows 32 MB and answers an oversized request with Anthropic's own error. ## 0.3.0 — 2026-09-28 - **Keep empty arguments when launching npm's `claude.cmd` on Windows.** Multi runs diff --git a/plugins/multi-core/hooks/register.ts b/plugins/multi-core/hooks/register.ts index d468270..fa747ed 100644 --- a/plugins/multi-core/hooks/register.ts +++ b/plugins/multi-core/hooks/register.ts @@ -22,11 +22,11 @@ type GatewayResponse = { // Claude Code 2.1.272 loads exactly one entry from hooks.json `modules`; compose here. export const register: Register = (on, options) => { const policyState: PolicyState = {}; - registerUsage(on, options); // Detaching makes the gateway forget this session's mode; keeping its generation // here would leave every later prompt stale against a gateway holding nothing. const agentModels = new Map(); const spawnModels = new Map(); + registerUsage(on, options, agentModels); registerLifecycle( on, options, diff --git a/plugins/multi-core/hooks/tests/workers.test.ts b/plugins/multi-core/hooks/tests/workers.test.ts index 2e84a82..d62dff5 100644 --- a/plugins/multi-core/hooks/tests/workers.test.ts +++ b/plugins/multi-core/hooks/tests/workers.test.ts @@ -371,3 +371,98 @@ test('the Agent tool description tells every model how to pick a provider model' expect(described.description).toContain('For a multi-* agent type'); expect(described.description.startsWith('Launch a new agent.')).toBe(true); }); + +/** The gateway classifies the type at offer, as it does before the model can name it. */ +async function offerClaudeType( + $: Parameters[1]>[0], + on: On, + agent: string, +) { + on('agent.offer', () => ({ isOffered: true })); + on('session.model', () => ({ value: 'claude-sonnet-5' })); + await $.agent.offer({ + agent, + description: agent, + source: 'plugin', + provider: { plugin: 'engine', tier: 'core' }, + }); +} + +/** A gateway that classifies every type as Claude-loop and records each call. */ +function claudeGateway(on: On) { + const sent: string[] = []; + on('env.get', (_$, event) => ({ + value: event.name === 'MULTI_GATEWAY_TOKEN' ? 'token' : 'http://127.0.0.1:4000', + })); + on('session.id', () => ({ value: 's' })); + on('session.cwd', () => ({ value: '/workspace' })); + on('http.fetch', (_$, event) => { + sent.push(event.url); + return { + value: { + ok: true, + status: 200, + headers: {}, + text: '{"execution":"claude","isOffered":true}', + }, + }; + }); + return sent; +} + +test('a native Claude subagent spawns without any gateway call', async ($, on) => { + const sent = claudeGateway(on); + on('agent.spawn', () => ({ model: 'haiku', agentId: 'native' })); + await offerClaudeType($, on, 'Explore'); + const before = sent.length; + const result = await $.agent.spawn({ + prompt: 'task', + subagentType: 'Explore', + model: 'haiku', + parentModel: 'claude-sonnet-5', + }); + expect(result.agentId).toBe('native'); + expect(sent.length).toBe(before); +}); + +test('a subagent of a Multi model session still reaches the gateway', async ($, on) => { + const sent = claudeGateway(on); + on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'inherited' })); + await offerClaudeType($, on, 'Explore'); + const before = sent.length; + await $.agent.spawn({ + prompt: 'task', + subagentType: 'Explore', + parentModel: 'multi/cursor/auto', + }); + expect(sent.length).toBeGreaterThan(before); +}); + +test('a type not offered as Claude-loop keeps gateway admission', async ($, on) => { + const sent = catalogGateway(on); + on('agent.spawn', () => ({ model: 'multi/cursor/auto', agentId: 'worker' })); + await $.agent.spawn({ prompt: 'task', subagentType: 'cursor-auto', parentModel: 'claude' }); + expect(sent.some((item) => item.url.endsWith('/multi/mod/worker-model'))).toBe(true); +}); + +test('a Claude tool call posts nothing to the gateway', async ($, on) => { + const sent = catalogGateway(on); + on('session.model', () => ({ value: 'claude-sonnet-5' })); + on('tool.call', () => ({ result: 'ran' })); + await $.tool.call({ tool: 'Read', tool_use_id: 'toolu_1' } as never); + expect(sent.length).toBe(0); +}); + +test('a provider model tool call is attributed to the gateway reviewer', async ($, on) => { + const sent = catalogGateway(on); + on('session.model', () => ({ value: 'multi/openai/gpt-6-astra' })); + on('tool.call', () => ({ result: 'ran' })); + await $.tool.call({ tool: 'Bash', tool_use_id: 'toolu_1' } as never); + expect(sent.map((item) => item.url.replace(/^.*(?=\/multi)/, ''))).toEqual(['/multi/permission']); + expect(sent[0]?.body).toEqual({ + session_id: 's', + tool_use_id: 'toolu_1', + tool_name: 'Bash', + cwd: '/workspace', + }); +}); diff --git a/plugins/multi-core/hooks/usage.ts b/plugins/multi-core/hooks/usage.ts index 070ba01..3d50248 100644 --- a/plugins/multi-core/hooks/usage.ts +++ b/plugins/multi-core/hooks/usage.ts @@ -10,8 +10,13 @@ export function forgetUsageSession(session: string) { panes.delete(session); } -export const register: Register = (on) => { +export const register = ( + on: Parameters[0], + _options?: Parameters[1], + agentModels: ReadonlyMap = new Map(), +) => { on('classic.PreToolUse', async ($, event, next) => { + await attributeToolCall($, event, agentModels); // `Task` is the Agent tool's legacy name; a worker's own spawn carries its agentId. const tool: string = event.tool; if ( @@ -224,3 +229,46 @@ async function request($: EngineInterface, route: string): Promise { clearTimeout(timer); } } + +/** + * Reviewer attribution for a provider's tool call: the gateway learns the session, call, + * and workspace it needs to match the reviewer request to it. A Claude loop's tool call + * is Claude's own and costs no gateway call. The reply never decides the call. + */ +async function attributeToolCall( + $: EngineInterface, + event: { tool: string; tool_use_id: string }, + agentModels: ReadonlyMap, +) { + const agentId = (event as { agentId?: string }).agentId; + const model = agentId === undefined ? await $.session.model() : agentModels.get(agentId); + const base = await $.env.get('MULTI_MOD_GATEWAY_URL'); + const token = await $.env.get('MULTI_GATEWAY_TOKEN'); + if (!model?.startsWith('multi/') || !base || !token) { + return; + } + const mode = (event as { permission_mode?: unknown }).permission_mode; + let timer: ReturnType | undefined; + try { + await Promise.race([ + $.http.fetch(`${base}/multi/permission`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-multi-gateway-token': token }, + body: JSON.stringify({ + session_id: await $.session.id(), + tool_use_id: event.tool_use_id, + tool_name: event.tool, + cwd: await $.session.cwd(), + ...(typeof mode === 'string' ? { permission_mode: mode } : {}), + }), + }), + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Attribution timeout')), 1500); + }), + ]); + } catch { + // Attribution is best effort; Claude's own checks decide the call. + } finally { + clearTimeout(timer); + } +} diff --git a/plugins/multi-core/hooks/workers.ts b/plugins/multi-core/hooks/workers.ts index 656f3ac..5e3e7ba 100644 --- a/plugins/multi-core/hooks/workers.ts +++ b/plugins/multi-core/hooks/workers.ts @@ -81,6 +81,11 @@ export const register = ( ) => { /** The resolved model of each provider-worker spawn, by its Agent call's tool_use_id. */ registerWorkerRows(on, agentModels, spawnModels); + /** + * Agent types the gateway classified as Claude-loop at offer. A definition may name a + * provider model the spawn event does not carry, so only such a type skips admission. + */ + const claudeTypes = new Set(); on('tool.describe', { tool: 'Agent' }, async ($, event, next) => { const described = await next(event); if (!(await active($))) { @@ -120,6 +125,11 @@ export const register = ( }, '/multi/mod/offer', ); + if (response?.execution === 'claude') { + claudeTypes.add(event.agent); + } else { + claudeTypes.delete(event.agent); + } // Claude owns its own catalog. Only a positively identified harness worker // is subject to Multi's settings-translation compatibility filter. return response?.execution === 'harness' && response.isOffered === false @@ -130,6 +140,14 @@ export const register = ( if (!(await active($))) { return next(event); } + // A native Claude subagent in a Claude session needs no gateway record. + if ( + claudeTypes.has(event.agent_type) && + !isProviderWorker(event.agent_type) && + !isMultiModel(await $.session.model()) + ) { + return next(event); + } const response = await request($, { sessionId: event.session_id, agentId: event.agent_id, @@ -147,6 +165,10 @@ export const register = ( if (!(await active($))) { return next(event); } + // A native Claude subagent runs on Claude Code's own path: no gateway call. + if (nativeClaudeSpawn(event, claudeTypes)) { + return next(event); + } const resolved = await providerSpawn($, event); if ('deny' in resolved) { return { deny: resolved.deny }; @@ -237,6 +259,23 @@ async function admit( : reportable(response?.error ?? 'Multi harness worker policy was not acknowledged.'); } +function isMultiModel(model: string | undefined): boolean { + return Boolean(model?.startsWith('multi/')); +} + +/** + * A Claude subagent: not a `multi-*` worker type, offered as Claude-loop, and neither its + * model nor its parent's (which a fork or an inheriting subagent runs on) is a Multi model. + */ +function nativeClaudeSpawn(event: SpawnEvent, claudeTypes: ReadonlySet): boolean { + return ( + claudeTypes.has(event.subagentType) && + !isProviderWorker(event.subagentType) && + !isMultiModel(event.model) && + !isMultiModel(event.parentModel) + ); +} + function harnessSpawn( event: { fork?: boolean; model?: string; parentModel?: string }, selection: GatewayResponse | undefined, diff --git a/plugins/multi-core/src/gateway/fetch.ts b/plugins/multi-core/src/gateway/fetch.ts index cc4014c..b352ebe 100644 --- a/plugins/multi-core/src/gateway/fetch.ts +++ b/plugins/multi-core/src/gateway/fetch.ts @@ -7,7 +7,9 @@ interface GatewayFetchInit { method: string; headers: Record; - body?: Buffer | string; + body?: Buffer | string | ReadableStream; + /** Node's fetch requires half duplex for streamed request bodies. */ + duplex?: 'half'; signal: AbortSignal; redirect: 'error'; } diff --git a/plugins/multi-core/src/gateway/server.ts b/plugins/multi-core/src/gateway/server.ts index 3f78d36..bd7cb91 100644 --- a/plugins/multi-core/src/gateway/server.ts +++ b/plugins/multi-core/src/gateway/server.ts @@ -44,7 +44,9 @@ import { originalToolNames } from './tools.ts'; const OPENAI_URL = 'https://chatgpt.com/backend-api/codex/responses'; const ANTHROPIC_URL = 'https://api.anthropic.com'; -const MAX_BODY = 8 * 1024 * 1024; +// Anthropic's own Messages API limit is 32 MB; the gateway buffers up to it and leaves +// the verdict to the provider, so a request Claude Code could send natively still goes. +const MAX_BODY = 32 * 1024 * 1024; const STRIPPED_REQUEST_HEADERS = [ 'host', 'connection', @@ -52,6 +54,12 @@ const STRIPPED_REQUEST_HEADERS = [ 'transfer-encoding', 'x-multi-gateway-token', 'accept-encoding', + 'keep-alive', + 'te', + 'trailer', + 'upgrade', + 'proxy-connection', + 'proxy-authorization', ]; const STRIPPED_RESPONSE_HEADERS = [ 'content-encoding', @@ -712,27 +720,81 @@ export function createNativeGateway({ if (req.method === 'POST') { forwarded = cleaned === body ? raw : Buffer.from(JSON.stringify(cleaned)); } - const upstream = await fetchImpl(ANTHROPIC_URL + url.pathname + url.search, { - method: req.method ?? 'GET', - headers, - body: forwarded, - signal, - redirect: 'error', - }); - onEvent({ route: 'anthropic', status: upstream.status, model: body.model }); + try { + const upstream = await fetchImpl(ANTHROPIC_URL + url.pathname + url.search, { + method: req.method ?? 'GET', + headers, + body: forwarded, + signal, + redirect: 'error', + }); + onEvent({ route: 'anthropic', status: upstream.status, model: body.model }); + await relayAnthropic( + upstream, + res, + signal, + body.tools?.length ? exchange.remember : undefined, + ); + } catch { + // Anthropic failures are not rebranded: Claude Code sees a dropped connection. + res.destroy(); + } + } + async function relayAnthropic( + upstream: Response, + res: http.ServerResponse, + signal: AbortSignal, + remember?: (tool: { id: string; name: string; input: unknown }) => void, + ) { const responseHeaders = Object.fromEntries(upstream.headers); for (const name of STRIPPED_RESPONSE_HEADERS) { delete responseHeaders[name]; } res.writeHead(upstream.status, responseHeaders); - if (guardAuto && upstream.ok && body.tools?.length) { - await forwardObservedTools(upstream, res, exchange.remember, signal); + if (guardAuto && upstream.ok && remember) { + await forwardObservedTools(upstream, res, remember, signal); } else if (upstream.body) { await pipeline(Readable.fromWeb(upstream.body), res); } else { res.end(); } } + /** Any /v1 request the gateway does not route itself goes to Anthropic as raw bytes. */ + async function handleRawAnthropic( + req: http.IncomingMessage, + res: http.ServerResponse, + url: URL, + abort: AbortController, + ) { + if (req.headers.origin) { + res.writeHead(403); + res.end('Forbidden'); + return; + } + if (blockAnthropic) { + throw new BadRequest('Anthropic is not signed in. Select an external model.'); + } + const method = req.method ?? 'GET'; + const hasBody = method !== 'GET' && method !== 'HEAD'; + const headers = anthropicHeaders(req); + if (req.headers['content-length'] !== undefined) { + headers['content-length'] = String(req.headers['content-length']); + } + try { + const upstream = await fetchImpl(ANTHROPIC_URL + url.pathname + url.search, { + method, + headers, + body: hasBody ? (Readable.toWeb(req) as ReadableStream) : undefined, + duplex: hasBody ? 'half' : undefined, + signal: providerSignal(abort.signal, null, timeoutMs), + redirect: 'error', + }); + onEvent({ route: 'anthropic', status: upstream.status, model: undefined }); + await relayAnthropic(upstream, res, abort.signal); + } catch { + res.destroy(); + } + } /** * Claude reports its current mode with each pending action, which catches EnterPlanMode * and ExitPlanMode inside a turn. A worker also stays bound by a planning parent's @@ -986,10 +1048,13 @@ export function createNativeGateway({ return res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...value })}\n\n`); }; try { + const url = new URL(req.url ?? '', 'http://localhost'); + if (isRawAnthropicPath(url.pathname)) { + return await handleRawAnthropic(req, res, url, abort); + } if (!authorizeRequest(req, res, token, guardAuto)) { return; } - const url = new URL(req.url ?? '', 'http://localhost'); const request = await readRequest(req, agentCatalog); const { parsed } = request; if (url.pathname.startsWith('/multi/mod/')) { @@ -1016,6 +1081,7 @@ export function createNativeGateway({ parsed, agentId, header(req.headers['x-claude-code-session-id']), + external !== null, ); sourceModel = String(body.model ?? ''); sourceSession = metadata.session; @@ -1060,6 +1126,13 @@ export function createNativeGateway({ class RequestTooLarge extends Error {} +/** Everything under /v1 except the Messages API the gateway parses and may route. */ +function isRawAnthropicPath(pathName: string) { + return ( + pathName.startsWith('/v1/') && !['/v1/messages', '/v1/messages/count_tokens'].includes(pathName) + ); +} + /** * Display rows are Claude Code UI: no provider sees them as tools or history. A * request that only answers a reply's rows is marked as that reply's follow-up. @@ -1199,6 +1272,7 @@ function requestIdentity( parsed: Record, agentId?: string, sessionHeader?: string, + provider = true, ) { const rawIdentity = isRecord(parsed.metadata) && typeof parsed.metadata.user_id === 'string' @@ -1215,7 +1289,9 @@ function requestIdentity( /* Unknown identity cannot grant auto capability. */ } } - if (session && sessionHeader && session !== sessionHeader) { + // Session identity matters to provider routes. A Claude passthrough request is + // never refused for it: Anthropic owns that request. + if (provider && session && sessionHeader && session !== sessionHeader) { throw new BadRequest('Session header and metadata disagree'); } session = session || sessionHeader || ''; @@ -1303,7 +1379,6 @@ function authorizeRequest( ![ '/v1/messages', '/v1/messages/count_tokens', - '/v1/models', '/api/hello', '/multi/mod/session', '/multi/mod/worker', @@ -1340,8 +1415,17 @@ function failResponse(res: http.ServerResponse, emit: Emit, error: unknown) { } const status = errorStatus(error); if (error instanceof RequestTooLarge) { - res.writeHead(413); - res.end('Request too large'); + // Anthropic's own 413 body, so Claude Code reads it as it would natively. + res.writeHead(413, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + type: 'error', + error: { + type: 'request_too_large', + message: 'Request exceeds the maximum allowed number of bytes.', + }, + }), + ); return; } const errorTypes: Record = { diff --git a/plugins/multi-core/src/gateway/tool-observer.ts b/plugins/multi-core/src/gateway/tool-observer.ts index 3b76f3a..ee84865 100644 --- a/plugins/multi-core/src/gateway/tool-observer.ts +++ b/plugins/multi-core/src/gateway/tool-observer.ts @@ -1,6 +1,5 @@ import { once } from 'node:events'; import type { ServerResponse } from 'node:http'; -import { readSse } from '../../../multi-openai/src/responses.ts'; interface Tool { id: string; @@ -78,7 +77,45 @@ export class ToolObserver { } } -/** Keep passthrough bytes unchanged and couple observation to client backpressure. */ +/** Incremental SSE reader for observation only; it throws when it cannot keep up. */ +class SseObserver { + private decoder = new TextDecoder(); + private pending = ''; + private data: string[] = []; + private observer: ToolObserver; + + constructor(observer: ToolObserver) { + this.observer = observer; + } + + push(chunk: Uint8Array) { + this.pending += this.decoder.decode(chunk, { stream: true }); + if (this.pending.length > MAX_BYTES) { + throw new Error('Observed SSE buffer exceeds 8 MiB'); + } + for (let index = this.pending.indexOf('\n'); index !== -1; index = this.pending.indexOf('\n')) { + const line = this.pending.slice(0, index).replace(/\r$/, ''); + this.pending = this.pending.slice(index + 1); + this.line(line); + } + } + + private line(line: string) { + if (line.startsWith('data:')) { + this.data.push(line.slice(5).replace(/^ /, '')); + } else if (!line && this.data.length) { + const value = this.data.join('\n'); + this.data = []; + this.observer.event(JSON.parse(value)); + } + } +} + +/** + * Pass the upstream bytes through unchanged and observe them on the side. An + * observation failure (partial tool JSON, oversized input) only stops observing; + * it never reaches Claude Code. The passthrough itself has no size cap. + */ export async function forwardObservedTools( upstream: Response, res: ServerResponse, @@ -90,32 +127,36 @@ export async function forwardObservedTools( return; } const observer = new ToolObserver(remember); - if (upstream.headers.get('content-type')?.includes('text/event-stream')) { - const source = upstream.body; - const forwarded = async function* () { - for await (const chunk of source) { - if (!res.write(chunk)) { - await once(res, 'drain', { signal }); - } - yield chunk; - } - }; - for await (const event of readSse(forwarded())) { - observer.event(event); + const streamed = upstream.headers.get('content-type')?.includes('text/event-stream'); + const sse = new SseObserver(observer); + const chunks: Uint8Array[] = []; + let bytes = 0; + let observing = true; + for await (const chunk of upstream.body) { + if (!res.write(chunk)) { + await once(res, 'drain', { signal }); + } + if (!observing) { + continue; } - } else { - const chunks: Uint8Array[] = []; - let bytes = 0; - for await (const chunk of upstream.body) { - bytes += chunk.length; - if (bytes > MAX_BYTES) { - throw new Error('Observed response exceeds 8 MiB'); + try { + if (streamed) { + sse.push(chunk); + } else { + bytes += chunk.length; + observing = bytes <= MAX_BYTES; + chunks.push(chunk); } - chunks.push(chunk); + } catch { + observing = false; + } + } + if (observing && !streamed) { + try { + observer.response(JSON.parse(Buffer.concat(chunks).toString('utf8'))); + } catch { + // Observation is best effort. } - const body = Buffer.concat(chunks); - observer.response(JSON.parse(body.toString('utf8'))); - res.write(body); } res.end(); } diff --git a/plugins/multi-core/src/launcher.ts b/plugins/multi-core/src/launcher.ts index 6cfcf2c..6e62598 100644 --- a/plugins/multi-core/src/launcher.ts +++ b/plugins/multi-core/src/launcher.ts @@ -74,7 +74,6 @@ import { DISPLAY_TOOL_SERVER } from './gateway/display-rows.ts'; import { executableInvocation, resolveExecutable } from './gateway/executable.ts'; import { ModBridge } from './gateway/mod-bridge.ts'; import { PermissionModes } from './gateway/mode-hook.ts'; -import { hookCommand } from './gateway/permission-hook.ts'; import type { ProviderUsageReader } from './gateway/provider-usage.ts'; import { ReceiptLedger } from './gateway/receipts.ts'; import type { GatewayEvent } from './gateway/server.ts'; @@ -946,17 +945,6 @@ function configureApproval( if (!anthropic && !nativeHarness && (!provider || !providers.includes(provider))) { settings.permissions = { ...settings.permissions, disableAutoMode: 'disable' }; } - // Observe tool workspace information for reviewer attribution. This hook never - // vetoes execution; Claude's checks and the actual reviewer request decide. - const command = hookCommand(new URL('./gateway/permission-hook.ts', import.meta.url)); - const hooks = settings.hooks as Record | undefined; - settings.hooks = { - ...hooks, - PreToolUse: [ - ...(hooks?.PreToolUse ?? []), - { hooks: [{ type: 'command', command, timeout: 10 }] }, - ], - }; } async function handleCommand(command?: string) { @@ -1179,9 +1167,6 @@ function gatewayEnvironment(port: number, token: string, anthropic: boolean) { return { ...env, CLAUDE_CODE_DISABLE_AGENT_VIEW: '1', - // Native runs and extended OpenAI reasoning can outlive Claude's default - // API timer; preserve explicit user limits. - API_TIMEOUT_MS: process.env.API_TIMEOUT_MS ?? '2147483647', ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`, // A custom base URL disables Claude's on-demand tool loading unless opted in. // We forward Claude tool references, so match the direct default of always diff --git a/test/unit/native-anthropic-passthrough.test.ts b/test/unit/native-anthropic-passthrough.test.ts new file mode 100644 index 0000000..6037738 --- /dev/null +++ b/test/unit/native-anthropic-passthrough.test.ts @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import type { TestContext } from 'node:test'; +import test from 'node:test'; +import type { GatewayFetch } from '../../plugins/multi-core/src/gateway/fetch.ts'; +import { createNativeGateway } from '../../plugins/multi-core/src/gateway/server.ts'; + +async function listen(t: TestContext, fetchImpl: GatewayFetch) { + const server = createNativeGateway({ + token: 'local-test-secret', + authFile: '/nonexistent/auth.json', + fetchImpl, + guardAuto: true, + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + t.after(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); + }); + const address = server.address(); + assert(address !== null && typeof address === 'object', 'Gateway port'); + return address.port; +} + +const truncated = [ + 'event: message_start\ndata: {"type":"message_start","message":{"id":"m"}}\n\n', + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":"Write","input":{}}}\n\n', + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"input_json_delta","partial_json":"{\\"file_path\\": \\"/tm"}}\n\n', + 'event: content_block_stop\ndata: {"type":"content_block_stop","index":0}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"max_tokens"}}\n\n', +].join(''); + +test('a tool_use truncated by max_tokens streams through byte for byte', async (t) => { + const port = await listen( + t, + async () => + new Response(truncated, { status: 200, headers: { 'content-type': 'text/event-stream' } }), + ); + const response = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': 'local-test-secret', 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-opus-4-6', + stream: true, + tools: [{ name: 'Write', input_schema: { type: 'object' } }], + messages: [], + }), + }); + assert.equal(response.status, 200); + const text = await response.text(); + assert.equal(text, truncated); + assert(!text.includes('Native gateway')); +}); + +test('Anthropic connection failures are not rebranded and upstream errors pass through', async (t) => { + let fail = true; + const port = await listen(t, async () => { + if (fail) { + throw new TypeError('fetch failed'); + } + return new Response('{"type":"error"}', { status: 529 }); + }); + const send = () => + fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'x-multi-gateway-token': 'local-test-secret' }, + body: JSON.stringify({ model: 'claude-opus-4-6', messages: [] }), + }); + await assert.rejects(send()); + fail = false; + const response = await send(); + assert.equal(response.status, 529); + assert.equal(await response.text(), '{"type":"error"}'); +}); + +test('a mid-stream Anthropic failure ends the response without an injected event', async (t) => { + const port = await listen(t, async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('event: ping\ndata: {"type":"ping"}\n\n')); + controller.error(new Error('upstream reset')); + }, + }); + return new Response(body, { headers: { 'content-type': 'text/event-stream' } }); + }); + let received = ''; + await new Promise((resolve) => { + const request = http.request( + `http://127.0.0.1:${port}/v1/messages`, + { method: 'POST', headers: { 'x-multi-gateway-token': 'local-test-secret' } }, + (res) => { + res.on('data', (chunk) => { + received += chunk; + }); + res.on('error', () => resolve()); + res.on('close', () => resolve()); + }, + ); + request.on('error', () => resolve()); + request.end(JSON.stringify({ model: 'claude-opus-4-6', messages: [] })); + }); + assert(!received.includes('Native gateway')); + assert(!received.includes('event: error')); +}); + +test('/v1 routes the gateway does not own are forwarded as raw bytes without its token', async (t) => { + const seen: { url: string; method: string; body: string; headers: Record }[] = []; + const port = await listen(t, async (url, init) => { + const body = init.body ? Buffer.from(await new Response(init.body).arrayBuffer()) : undefined; + seen.push({ + url, + method: init.method, + body: body?.toString('latin1') ?? '', + headers: init.headers, + }); + return new Response('ok', { status: 200, headers: { 'content-type': 'text/plain' } }); + }); + const bytes = Buffer.from([0, 255, 13, 10, 45, 45, 1, 2, 3]); + const upload = await fetch(`http://127.0.0.1:${port}/v1/files?beta=true`, { + method: 'POST', + headers: { 'content-type': 'multipart/form-data; boundary=x', 'x-api-key': 'caller-key' }, + body: bytes, + }); + assert.equal(upload.status, 200); + const content = await fetch(`http://127.0.0.1:${port}/v1/files/file_1/content`, { + headers: { authorization: 'Bearer caller' }, + }); + assert.equal(await content.text(), 'ok'); + assert.equal(seen[0].url, 'https://api.anthropic.com/v1/files?beta=true'); + assert.equal(seen[0].method, 'POST'); + assert.equal(seen[0].body, bytes.toString('latin1')); + assert.equal(seen[0].headers['x-api-key'], 'caller-key'); + assert.equal(seen[0].headers['content-type'], 'multipart/form-data; boundary=x'); + assert.equal(seen[1].url, 'https://api.anthropic.com/v1/files/file_1/content'); + assert.equal(seen[1].method, 'GET'); + assert.equal(seen[1].headers['x-multi-gateway-token'], undefined); + const gated = await fetch(`http://127.0.0.1:${port}/multi/mod/session`, { method: 'POST' }); + assert.equal(gated.status, 401); +}); diff --git a/test/unit/native-gateway.test.ts b/test/unit/native-gateway.test.ts index 58d9e56..359b29b 100644 --- a/test/unit/native-gateway.test.ts +++ b/test/unit/native-gateway.test.ts @@ -1238,13 +1238,14 @@ test('oversized uploads receive HTTP 413 while the client is still streaming', a signal: AbortSignal.timeout(5000), body: new ReadableStream({ start(controller) { - controller.enqueue(new Uint8Array(8 * 1024 * 1024 + 1)); + controller.enqueue(new Uint8Array(32 * 1024 * 1024 + 1)); }, }), }; const response = await fetch(`http://127.0.0.1:${address.port}/v1/messages`, init); assert.equal(response.status, 413); - assert.equal(await response.text(), 'Request too large'); + const body = (await response.json()) as { error: { type: string } }; + assert.equal(body.error.type, 'request_too_large'); }); test('OpenAI and Claude passthrough have no implicit request deadline while explicit limits bound both', async (t) => { @@ -1272,6 +1273,18 @@ test('OpenAI and Claude passthrough have no implicit request deadline while expl assert.deepEqual(durations, [25, 25]); }); +test('a Claude passthrough request is never refused for disagreeing session identity, an external one is', async (t) => { + const call = await gateway(t, async (url) => + url.includes('api.anthropic.com') + ? new Response('{}', { headers: { 'content-type': 'application/json' } }) + : new Response(sse(textEvents)), + ); + const metadata = { user_id: JSON.stringify({ session_id: 'from-metadata' }) }; + const headers = { 'x-claude-code-session-id': 'from-header' }; + assert.equal((await call({ model: 'claude-sonnet-5', messages, metadata }, headers)).status, 200); + assert.equal((await call({ ...body, metadata }, headers)).status, 400); +}); + test('an explicit OpenAI timeout aborts upstream inference', async (t) => { let aborted = false; const call = await gateway( diff --git a/test/unit/native-launcher.test.ts b/test/unit/native-launcher.test.ts index 2c5d95a..13a986d 100644 --- a/test/unit/native-launcher.test.ts +++ b/test/unit/native-launcher.test.ts @@ -102,7 +102,11 @@ const settings=JSON.parse(fs.readFileSync(args[args.indexOf('--settings')+1],'ut assert.equal(result.agentView, '1'); assert.equal(result.backgroundTasks, undefined); assert.equal(result.functionHooks, '1'); - assert.equal(result.apiTimeout, auth === 'api' ? '1000' : '2147483647'); + assert.equal( + result.apiTimeout, + auth === 'api' ? '1000' : undefined, + 'Claude keeps its native request timeout unless the user sets one', + ); assert.equal(result.toolSearch, auth === 'api' ? 'false' : 'true'); assert.deepEqual(result.settings.permissions.deny, ['Bash(denied)']); assert.equal( @@ -115,7 +119,11 @@ const settings=JSON.parse(fs.readFileSync(args[args.indexOf('--settings')+1],'ut 'local hooks authenticate independently of Claude login', ); assert.equal(result.auth, { no: 'local', api: 'api', yes: 'native' }[auth]); - assert.equal(result.settings.hooks.PreToolUse?.length, 1); + assert.equal( + result.settings.hooks.PreToolUse, + undefined, + 'no command hook runs on Claude tool calls', + ); } const baseEnvironment = { PATH: path.join(cwd, 'bin') + path.delimiter + process.env.PATH, diff --git a/test/unit/native-reviewer-discovery.test.ts b/test/unit/native-reviewer-discovery.test.ts index e8ad3e8..14e03d0 100644 --- a/test/unit/native-reviewer-discovery.test.ts +++ b/test/unit/native-reviewer-discovery.test.ts @@ -104,7 +104,7 @@ globalThis.fetch = async (url, init) => { settings.permissions?.disableAutoMode, review === 'yes' || auth !== 'no' ? undefined : 'disable', ); - assert.equal(settings.hooks.PreToolUse.length, 1); + assert.equal(settings.hooks?.PreToolUse, undefined); } } const catalogCalls = (await readFile(calls, 'utf8')) diff --git a/test/unit/native-usage-menu.test.ts b/test/unit/native-usage-menu.test.ts index c0b6a23..627d5ee 100644 --- a/test/unit/native-usage-menu.test.ts +++ b/test/unit/native-usage-menu.test.ts @@ -134,7 +134,7 @@ test('quota advice is opt-in, session scoped, advisory and removed on detach', a env: { get: async (name: string) => (name === 'MULTI_GATEWAY_TOKEN' ? 'secret' : 'http://localhost'), }, - session: { id: async () => session }, + session: { id: async () => session, model: async () => 'claude-sonnet-5' }, http: { fetch: async () => { reads++; From 60a2c9b322096a9443e592ee11c215e9d350d099 Mon Sep 17 00:00:00 2001 From: greenpolo Date: Tue, 29 Sep 2026 16:35:24 -0400 Subject: [PATCH 2/2] Add GPT-6.1 Sol to the Zen catalog Zen serves gpt-6.1-sol; Codex refuses it on ChatGPT plans, so the OpenAI provider keeps gpt-6-sol. It is opt-in and not in the default Zen picker. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01659JR7q9uk8UxjbNTAr3R3 --- CHANGELOG.md | 5 +++++ docs/zen.md | 2 +- plugins/multi-zen/src/models.ts | 11 +++++++++++ test/unit/native-zen-models.test.ts | 1 + 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff8dff3..8234871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,11 @@ See [ARCHITECTURE.md](ARCHITECTURE.md) for current direction and request over 8 MiB, so a Claude conversation with a few screenshots or PDFs failed with "Request too large (max 32MB)" well before Anthropic's limit. It now allows 32 MB and answers an oversized request with Anthropic's own error. +- **Add GPT-6.1 Sol to the Zen catalog.** Select it with + `/model multi/zen/gpt-6.1-sol` or `model: gpt-6.1-sol` on a `multi-zen` worker. + Codex does not serve it on ChatGPT plans yet, so the OpenAI provider still runs + `gpt-6-sol`. + ## 0.3.0 — 2026-09-28 - **Keep empty arguments when launching npm's `claude.cmd` on Windows.** Multi runs diff --git a/docs/zen.md b/docs/zen.md index ee8e842..0eb0e9b 100644 --- a/docs/zen.md +++ b/docs/zen.md @@ -30,7 +30,7 @@ below lists the full supported catalog; other rows require explicit selection. | Model IDs | Protocol | Effort | | --- | --- | --- | | `deepseek-v4.1-flash`, `deepseek-v4-flash`, `kimi-k3`, `glm-5.3`, `glm-5.3-flash`, `glm-5.2`, `minimax-m2.7` | Chat Completions | Provider-native reasoning | -| `gpt-6-luna`, `gpt-6-sol`, `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol`, `muse-spark-1.3` | Responses | `low`, `medium`, `high`, `xhigh`, and `max` | +| `gpt-6.1-sol`, `gpt-6-luna`, `gpt-6-sol`, `gpt-5.6-luna`, `gpt-5.6-terra`, `gpt-5.6-sol`, `muse-spark-1.3` | Responses | `low`, `medium`, `high`, `xhigh`, and `max` | Workers: the Agent tool's `multi-zen` type runs the Zen rows the session's `/model` picker shows (the curated defaults unless `--models` selects others); diff --git a/plugins/multi-zen/src/models.ts b/plugins/multi-zen/src/models.ts index 627c5de..9cbed33 100644 --- a/plugins/multi-zen/src/models.ts +++ b/plugins/multi-zen/src/models.ts @@ -23,6 +23,17 @@ const GPT_EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'] as const satisfies // github.com/anomalyco/opencode/blob/830d5eb5354874105cc31599635a80c1662609e8/packages/opencode/test/tool/fixtures/models-api.json // Zen's /models endpoint exposes IDs only, so capabilities stay explicit and conservative. export const ZEN_MODELS: readonly ZenModel[] = Object.freeze([ + // Zen serves it; Codex refuses it on ChatGPT plans (2026-09-29), so it has no OpenAI row. + { + id: 'gpt-6.1-sol', + protocol: 'responses', + label: 'GPT-6.1 Sol', + description: 'OpenCode Zen · GPT Responses', + efforts: GPT_EFFORTS, + images: true, + documents: true, + maxOutputTokens: 128000, + }, { id: 'gpt-6-luna', protocol: 'responses', diff --git a/test/unit/native-zen-models.test.ts b/test/unit/native-zen-models.test.ts index f3feaba..d59244e 100644 --- a/test/unit/native-zen-models.test.ts +++ b/test/unit/native-zen-models.test.ts @@ -173,6 +173,7 @@ test('Zen catalog exposes bounded protocols and only supported effort workers', assert.deepEqual( ZEN_MODELS.map((model) => [model.id, model.protocol]), [ + ['gpt-6.1-sol', 'responses'], ['gpt-6-luna', 'responses'], ['gpt-6-sol', 'responses'], ['gpt-5.6-luna', 'responses'],