diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b9d055..8234871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ 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. +- **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-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/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-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++; 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'],