diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 770f4410..dc509769 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -40,7 +40,10 @@ src/ │ ├── adapter.ts ← AgentAdapter interface (extensibility point for multi-agent support) │ ├── adapters/ │ │ ├── opencode.ts ← OpenCode adapter (session headers, CWD extraction, tool config) -│ │ └── forgecode.ts ← ForgeCode adapter (fingerprint sessions, XML CWD, passthrough) +│ │ ├── forgecode.ts ← ForgeCode adapter (fingerprint sessions, XML CWD, passthrough) +│ │ └── amp.ts ← Amp adapter (snake_case native tools, x-amp-thread-id session, passthrough) +│ ├── passthrough/ +│ │ └── ampForwarder.ts ← Amp selective HTTP forward proxy (non-inference traffic → AMP_UPSTREAM_URL) │ ├── query.ts ← SDK query options builder (shared between stream/non-stream paths) │ ├── errors.ts ← Error classification (SDK errors → HTTP responses) │ ├── models.ts ← Model mapping, Claude executable resolution diff --git a/README.md b/README.md index 0a6b9249..10ce17b7 100644 --- a/README.md +++ b/README.md @@ -488,6 +488,90 @@ MERIDIAN_DEFAULT_AGENT=forgecode meridian ForgeCode uses reqwest's default User-Agent, so automatic detection isn't possible. The `MERIDIAN_DEFAULT_AGENT` env var tells Meridian to use the ForgeCode adapter for all unrecognized requests. If you run other agents alongside ForgeCode, use the `x-meridian-agent: forgecode` header instead (add `[providers.headers]` to your `.forge.toml`). +### Amp (Sourcegraph) + +[Amp](https://ampcode.com) is Sourcegraph's coding agent (npm: `@sourcegraph/amp`). Meridian's Amp adapter uses **selective passthrough**: Claude inference (Amp's `smart` mode) routes through your Claude Max subscription, while every other Amp endpoint (threads sync, attachments, telemetry, login, usage, web UI, code review) is forwarded transparently to `https://ampcode.com` so the entire Amp app keeps working. + +**Step 1 — install Amp and log in against `ampcode.com` once** to acquire your API key: + +```bash +npm install -g @sourcegraph/amp +amp login +``` + +**Step 2 — wire Amp to Meridian.** Amp keys credentials by server URL, so we tell it the new URL via its settings file *and* register the same key under that URL in its secrets store. After this, plain `amp` works — no env vars needed. + +```bash +# Point Amp's CLI at Meridian +mkdir -p ~/.config/amp +cat > ~/.config/amp/settings.json <<'EOF' +{ + "amp.url": "http://127.0.0.1:3456" +} +EOF + +# Register your existing key under the local URL. +# (Amp stores secrets keyed by URL verbatim — note: no trailing slash here, must +# match the value in settings.json exactly.) +python3 - <<'PY' +import json, os +p = os.path.expanduser("~/.local/share/amp/secrets.json") +d = json.load(open(p)) +d["apiKey@http://127.0.0.1:3456"] = d["apiKey@https://ampcode.com/"] +json.dump(d, open(p, "w"), indent=2) +PY +chmod 600 ~/.local/share/amp/secrets.json +``` + +That's it. Now just run `amp` like normal: + +```bash +amp # interactive +amp -x "say hi" # headless +amp threads list +``` + +If you'd rather use env vars instead of editing config files (e.g., for one-off runs against a different Meridian port), this also works: + +```bash +export AMP_URL=http://127.0.0.1:3456 +export AMP_API_KEY=$(python3 -c "import json,os; print(json.load(open(f'{os.path.expanduser(\"~\")}/.local/share/amp/secrets.json'))['apiKey@https://ampcode.com/'])") +amp -x "say hi" +``` + +#### Billing — what's free, what isn't + +**Default usage is free.** Amp defaults to `smart` mode, which uses Claude. Through Meridian that maps directly onto your Claude Max subscription — no charge, no token billing, no Sourcegraph credit consumption. + +You only pay Sourcegraph when you **explicitly opt into a non-Claude mode**, which uses a different upstream provider Meridian can't intercept: + +| Command | Routed through | Who pays | +|---|---|---| +| `amp` (interactive) | Meridian → Claude Max | **Free** (your Max sub) | +| `amp -x "..."` | Meridian → Claude Max | **Free** | +| `amp --mode deep ...` | Forwarder → ampcode.com (GPT-5.5) | Sourcegraph (paid Amp tier required) | +| `amp --mode large ...` / `--mode rush ...` | Forwarder → ampcode.com | Sourcegraph (paid Amp tier required) | +| `amp threads list / share / search / ...` | Forwarder → ampcode.com | Free (no inference) | +| `amp skill list / tools list / mcp list / ...` | Local | Free | +| `amp usage` | Forwarder → ampcode.com | Free (just reads your account) | + +Meridian never originates a charge. It only intercepts Anthropic/Claude requests; non-Claude providers pass through unchanged so Sourcegraph bills exactly as it would have if you weren't using Meridian. Free-tier Amp users hit Sourcegraph's existing 402 paywall on `amp -x` with non-Claude modes; that's Sourcegraph's gate, not ours. + +#### Configuration + +| Env var | Default | Purpose | +|---|---|---| +| `AMP_UPSTREAM_URL` | `https://ampcode.com` | Where to forward non-inference traffic | +| `MERIDIAN_AMP_FORWARD_DISABLED` | unset | Set to `true` to disable forwarding entirely (only inference works; threads/sync/etc. break) | + +#### Known limitations + +- **One-time `amp login` against real `ampcode.com`** is required to acquire `AMP_API_KEY`. The login flow itself can't be completed through Meridian (the OAuth callback needs Sourcegraph's real login page). +- **Amp keys credentials by server URL.** Your stored key is registered for `https://ampcode.com`, not `http://127.0.0.1:3456`. The setup above writes a second entry under the local URL (no trailing slash — must match `amp.url` exactly) so plain `amp` works without env vars. +- **Non-Claude modes still bill against your Sourcegraph account.** `amp --mode deep/large/rush` use providers Meridian can't intercept; the forwarder passes those requests through to `ampcode.com` and Sourcegraph charges normally. See "Billing" above for the full breakdown. +- **Live thread sync via WebSockets** (multi-device updates without polling) goes through the catch-all forwarder; HTTP routes are verified, but the WS upgrade path hasn't been live-tested. Polling-based `amp threads list` works fine. +- **Multimodal (image attachments) not yet live-verified.** Should work since Amp uses Claude's standard image format, but no end-to-end confirmation in this release. + ### Pi Pi uses the `@mariozechner/pi-ai` library which supports a configurable `baseUrl` on the model. Add a provider-level override in `~/.pi/agent/models.json`: @@ -557,6 +641,7 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:3456 | [Open WebUI](https://github.com/open-webui/open-webui) | ✅ Verified | OpenAI-compatible endpoints — set base URL to `http://127.0.0.1:3456` | | [Pi](https://github.com/mariozechner/pi-coding-agent) | ✅ Verified | models.json config (see above) — requires `MERIDIAN_DEFAULT_AGENT=pi` | | [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | ✅ Verified | `ANTHROPIC_BASE_URL` — remote clients share a Max subscription over the network; client CWD preserved in system prompt | +| [Amp (Sourcegraph)](https://ampcode.com) | ✅ Verified | `AMP_URL` — selective passthrough (Claude inference free; threads/attachments/telemetry forwarded to ampcode.com) | | [Continue](https://github.com/continuedev/continue) | 🔲 Untested | OpenAI-compatible endpoints should work — set `apiBase` to `http://127.0.0.1:3456` | Tested an agent or built a plugin? [Open an issue](https://github.com/rynfar/meridian/issues) and we'll add it. @@ -568,13 +653,16 @@ src/proxy/ ├── server.ts ← HTTP orchestration (routes, SSE streaming, concurrency) ├── adapter.ts ← AgentAdapter interface ├── adapters/ -│ ├── detect.ts ← Agent detection from request headers +│ ├── detect.ts ← Agent detection from request headers / paths │ ├── opencode.ts ← OpenCode adapter │ ├── forgecode.ts ← ForgeCode adapter │ ├── crush.ts ← Crush adapter │ ├── droid.ts ← Droid adapter │ ├── pi.ts ← Pi adapter +│ ├── amp.ts ← Amp adapter (snake_case tools, x-amp-thread-id) │ └── passthrough.ts ← LiteLLM passthrough adapter +├── passthrough/ +│ └── ampForwarder.ts ← Selective HTTP forward proxy → AMP_UPSTREAM_URL ├── query.ts ← SDK query options builder ├── errors.ts ← Error classification ├── models.ts ← Model mapping (sonnet/opus/haiku, agentMode) diff --git a/src/__tests__/adapter-detection.test.ts b/src/__tests__/adapter-detection.test.ts index 1ef7bb21..5caa75a4 100644 --- a/src/__tests__/adapter-detection.test.ts +++ b/src/__tests__/adapter-detection.test.ts @@ -411,3 +411,67 @@ describe("detectAdapter — adapter contracts", () => { expect(adapter.usesPassthrough).toBeUndefined() }) }) + +describe("detectAdapter — Amp", () => { + function makeCtx(overrides: { path?: string; headers?: Record } = {}): any { + const headers = overrides.headers ?? {} + return { + req: { + path: overrides.path ?? "/v1/messages", + header: (name?: string) => { + if (name === undefined) return headers + return headers[name.toLowerCase()] ?? headers[name] + }, + }, + } + } + + it("path /api/provider/anthropic/v1/messages → amp", () => { + const { detectAdapter } = require("../proxy/adapters/detect") + const adapter = detectAdapter(makeCtx({ path: "/api/provider/anthropic/v1/messages" })) + expect(adapter.name).toBe("amp") + }) + + it("path /api/provider/anthropic/v1/messages/count_tokens → amp", () => { + const { detectAdapter } = require("../proxy/adapters/detect") + const adapter = detectAdapter(makeCtx({ path: "/api/provider/anthropic/v1/messages/count_tokens" })) + expect(adapter.name).toBe("amp") + }) + + it("x-amp-client-type header alone → amp", () => { + const { detectAdapter } = require("../proxy/adapters/detect") + const adapter = detectAdapter(makeCtx({ headers: { "x-amp-client-type": "cli" } })) + expect(adapter.name).toBe("amp") + }) + + it("x-amp-client-application header alone → amp", () => { + const { detectAdapter } = require("../proxy/adapters/detect") + const adapter = detectAdapter(makeCtx({ headers: { "x-amp-client-application": "amp-cli" } })) + expect(adapter.name).toBe("amp") + }) + + it("/api/thread-actors path with x-amp-client-type → amp", () => { + const { detectAdapter } = require("../proxy/adapters/detect") + const adapter = detectAdapter(makeCtx({ + path: "/api/thread-actors", + headers: { "x-amp-client-type": "cli" }, + })) + expect(adapter.name).toBe("amp") + }) + + it("OpenCode session header still wins when no Amp signals", () => { + const { detectAdapter } = require("../proxy/adapters/detect") + const adapter = detectAdapter(makeCtx({ + headers: { "x-opencode-session": "sess-1" }, + })) + expect(adapter.name).toBe("opencode") + }) + + it("explicit x-meridian-agent: amp wins", () => { + const { detectAdapter } = require("../proxy/adapters/detect") + const adapter = detectAdapter(makeCtx({ + headers: { "x-meridian-agent": "amp" }, + })) + expect(adapter.name).toBe("amp") + }) +}) diff --git a/src/__tests__/amp-adapter.test.ts b/src/__tests__/amp-adapter.test.ts new file mode 100644 index 00000000..405ac27d --- /dev/null +++ b/src/__tests__/amp-adapter.test.ts @@ -0,0 +1,203 @@ +/** + * Tests for the Amp agent adapter. + */ +import { describe, it, expect } from "bun:test" +import { ampAdapter } from "../proxy/adapters/amp" + +describe("ampAdapter — identity", () => { + it("has name 'amp'", () => { + expect(ampAdapter.name).toBe("amp") + }) + + it("getMcpServerName returns 'amp'", () => { + expect(ampAdapter.getMcpServerName()).toBe("amp") + }) +}) + +describe("ampAdapter.getSessionId", () => { + it("reads x-amp-thread-id header", () => { + const ctx = { + req: { + header: (name: string) => + name === "x-amp-thread-id" ? "T-019d01b5-f70d-73ea-9445-f6d358f7213e" : undefined, + }, + } + expect(ampAdapter.getSessionId(ctx as any)).toBe("T-019d01b5-f70d-73ea-9445-f6d358f7213e") + }) + + it("returns undefined when header is absent", () => { + const ctx = { req: { header: () => undefined } } + expect(ampAdapter.getSessionId(ctx as any)).toBeUndefined() + }) + + it("does not fall back to other agents' headers", () => { + const ctx = { + req: { + header: (name: string) => + name === "x-opencode-session" ? "sess-abc" : undefined, + }, + } + expect(ampAdapter.getSessionId(ctx as any)).toBeUndefined() + }) +}) + +describe("ampAdapter.normalizeContent", () => { + it("normalizes string content", () => { + expect(ampAdapter.normalizeContent("hello world")).toBe("hello world") + }) + + it("normalizes array of text blocks", () => { + const content = [ + { type: "text", text: "First block" }, + { type: "text", text: "Second block" }, + ] + const result = ampAdapter.normalizeContent(content) + expect(result).toContain("First block") + expect(result).toContain("Second block") + }) + + it("normalizes tool_use blocks", () => { + const content = [ + { type: "tool_use", id: "tu_1", name: "bash", input: { command: "ls" } }, + ] + const result = ampAdapter.normalizeContent(content) + expect(result).toContain("tool_use") + expect(result).toContain("bash") + }) + + it("handles null content", () => { + expect(ampAdapter.normalizeContent(null as any)).toBe("null") + }) +}) + +describe("ampAdapter tool configuration", () => { + it("getBlockedBuiltinTools includes SDK PascalCase tool names", () => { + const blocked = ampAdapter.getBlockedBuiltinTools() + expect(blocked).toContain("Read") + expect(blocked).toContain("Write") + expect(blocked).toContain("Edit") + expect(blocked).toContain("Bash") + expect(blocked).toContain("Glob") + expect(blocked).toContain("Grep") + }) + + it("getBlockedBuiltinTools does NOT include Amp's snake_case tool names", () => { + const blocked = ampAdapter.getBlockedBuiltinTools() + expect(blocked).not.toContain("read_file") + expect(blocked).not.toContain("edit_file") + expect(blocked).not.toContain("create_file") + expect(blocked).not.toContain("bash") + expect(blocked).not.toContain("todo_write") + }) + + it("getAgentIncompatibleTools includes Claude-Code-only tools", () => { + const incompatible = ampAdapter.getAgentIncompatibleTools() + expect(incompatible).toContain("EnterPlanMode") + expect(incompatible).toContain("CronCreate") + expect(incompatible).toContain("EnterWorktree") + }) + + it("getMcpServerName returns 'amp'", () => { + expect(ampAdapter.getMcpServerName()).toBe("amp") + }) + + it("getAllowedMcpTools returns exactly 6 tools", () => { + expect(ampAdapter.getAllowedMcpTools()).toHaveLength(6) + }) + + it("getAllowedMcpTools all have mcp__amp__ prefix", () => { + for (const tool of ampAdapter.getAllowedMcpTools()) { + expect(tool).toStartWith("mcp__amp__") + } + }) + + it("getAllowedMcpTools covers the standard set", () => { + const tools = ampAdapter.getAllowedMcpTools() + expect(tools).toContain("mcp__amp__read") + expect(tools).toContain("mcp__amp__write") + expect(tools).toContain("mcp__amp__edit") + expect(tools).toContain("mcp__amp__bash") + expect(tools).toContain("mcp__amp__glob") + expect(tools).toContain("mcp__amp__grep") + }) +}) + +describe("ampAdapter behavior flags", () => { + it("usesPassthrough returns true", () => { + expect(ampAdapter.usesPassthrough!()).toBe(true) + }) + + it("supportsThinking returns true", () => { + expect(ampAdapter.supportsThinking!()).toBe(true) + }) + + it("shouldTrackFileChanges returns false (Amp surfaces edits natively)", () => { + expect(ampAdapter.shouldTrackFileChanges!()).toBe(false) + }) + + it("buildSdkAgents returns empty object", () => { + expect(ampAdapter.buildSdkAgents!({}, [])).toEqual({}) + }) + + it("buildSdkHooks returns undefined", () => { + expect(ampAdapter.buildSdkHooks!({}, {})).toBeUndefined() + }) + + it("buildSystemContextAddendum returns empty string", () => { + expect(ampAdapter.buildSystemContextAddendum!({}, {})).toBe("") + }) +}) + +describe("ampAdapter.extractFileChangesFromToolUse", () => { + it("detects create_file as 'wrote'", () => { + const changes = ampAdapter.extractFileChangesFromToolUse!("create_file", { path: "/tmp/new.ts", content: "x" }) + expect(changes).toEqual([{ operation: "wrote", path: "/tmp/new.ts" }]) + }) + + it("detects create_file with file_path field", () => { + const changes = ampAdapter.extractFileChangesFromToolUse!("create_file", { file_path: "/tmp/new.ts" }) + expect(changes).toEqual([{ operation: "wrote", path: "/tmp/new.ts" }]) + }) + + it("detects edit_file as 'edited'", () => { + const changes = ampAdapter.extractFileChangesFromToolUse!("edit_file", { path: "/tmp/existing.ts", old_str: "a", new_str: "b" }) + expect(changes).toEqual([{ operation: "edited", path: "/tmp/existing.ts" }]) + }) + + it("detects bash command with redirect", () => { + const changes = ampAdapter.extractFileChangesFromToolUse!("bash", { command: "echo hello > /tmp/out.txt" }) + expect(changes.length).toBeGreaterThan(0) + expect(changes[0]!.path).toBe("/tmp/out.txt") + }) + + it("returns empty for read_file (no mutation)", () => { + expect(ampAdapter.extractFileChangesFromToolUse!("read_file", { path: "/tmp/x" })).toEqual([]) + }) + + it("returns empty for glob/grep/search (no mutation)", () => { + expect(ampAdapter.extractFileChangesFromToolUse!("glob", { pattern: "*.ts" })).toEqual([]) + expect(ampAdapter.extractFileChangesFromToolUse!("grep", { query: "TODO" })).toEqual([]) + expect(ampAdapter.extractFileChangesFromToolUse!("search", { q: "x" })).toEqual([]) + }) + + it("returns empty for todo_write / task (not file ops)", () => { + expect(ampAdapter.extractFileChangesFromToolUse!("todo_write", { todos: [] })).toEqual([]) + expect(ampAdapter.extractFileChangesFromToolUse!("task", { description: "do x" })).toEqual([]) + }) + + it("returns empty for create_file with no path", () => { + expect(ampAdapter.extractFileChangesFromToolUse!("create_file", { content: "x" })).toEqual([]) + }) + + it("returns empty for bash with no command", () => { + expect(ampAdapter.extractFileChangesFromToolUse!("bash", {})).toEqual([]) + }) + + it("returns empty for null input", () => { + expect(ampAdapter.extractFileChangesFromToolUse!("create_file", null)).toEqual([]) + }) + + it("returns empty for unknown tool", () => { + expect(ampAdapter.extractFileChangesFromToolUse!("fetch", { url: "https://example.com" })).toEqual([]) + }) +}) diff --git a/src/__tests__/amp-forwarder.test.ts b/src/__tests__/amp-forwarder.test.ts new file mode 100644 index 00000000..cf61437b --- /dev/null +++ b/src/__tests__/amp-forwarder.test.ts @@ -0,0 +1,248 @@ +/** + * Tests for the Amp HTTP forward proxy. + * + * These tests start a real upstream server on a random port, + * point the forwarder at it via AMP_UPSTREAM_URL, and verify + * the forwarder preserves method, path, body, headers, status, and + * response body. + */ +import { describe, it, expect, beforeEach, afterEach } from "bun:test" +import { ampForwardRequest } from "../proxy/passthrough/ampForwarder" + +let upstreamServer: ReturnType | undefined +let upstreamRequests: Array<{ method: string; path: string; headers: Record; body: string }> = [] +let upstreamResponse: { status: number; headers: Record; body: string } = { + status: 200, headers: { "content-type": "application/json" }, body: '{"ok":true}', +} + +beforeEach(() => { + upstreamRequests = [] + upstreamResponse = { status: 200, headers: { "content-type": "application/json" }, body: '{"ok":true}' } + upstreamServer = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + const headers: Record = {} + req.headers.forEach((v, k) => { headers[k] = v }) + const body = req.body ? await req.text() : "" + upstreamRequests.push({ method: req.method, path: url.pathname + url.search, headers, body }) + return new Response(upstreamResponse.body, { + status: upstreamResponse.status, + headers: upstreamResponse.headers, + }) + }, + }) + process.env.AMP_UPSTREAM_URL = `http://127.0.0.1:${upstreamServer.port}` +}) + +afterEach(() => { + upstreamServer?.stop() + delete process.env.AMP_UPSTREAM_URL +}) + +function makeCtx(opts: { method: string; path: string; body?: string; headers?: Record }) { + const url = `http://meridian.local${opts.path}` + const reqInit: RequestInit = { + method: opts.method, + headers: opts.headers ?? {}, + } + if (opts.body !== undefined) reqInit.body = opts.body + const request = new Request(url, reqInit) + return { + req: { + raw: request, + method: opts.method, + path: opts.path, + url, + header: (name?: string) => { + if (name === undefined) return opts.headers ?? {} + return (opts.headers ?? {})[name.toLowerCase()] ?? (opts.headers ?? {})[name] + }, + }, + } as any +} + +describe("ampForwardRequest — REST", () => { + it("forwards GET with path and query preserved", async () => { + const ctx = makeCtx({ method: "GET", path: "/api/thread-actors?limit=10" }) + const res = await ampForwardRequest(ctx) + expect(res.status).toBe(200) + expect(upstreamRequests).toHaveLength(1) + expect(upstreamRequests[0]!.method).toBe("GET") + expect(upstreamRequests[0]!.path).toBe("/api/thread-actors?limit=10") + }) + + it("forwards POST with body preserved", async () => { + const ctx = makeCtx({ + method: "POST", + path: "/api/thread-actors", + body: '{"hello":"world"}', + headers: { "content-type": "application/json" }, + }) + const res = await ampForwardRequest(ctx) + expect(res.status).toBe(200) + expect(upstreamRequests[0]!.body).toBe('{"hello":"world"}') + expect(upstreamRequests[0]!.headers["content-type"]).toBe("application/json") + }) + + it("forwards Authorization and x-amp-* headers", async () => { + const ctx = makeCtx({ + method: "GET", + path: "/api/thread-actors", + headers: { + "authorization": "Bearer amp-key-123", + "x-amp-thread-id": "T-abc", + "x-amp-client-type": "cli", + }, + }) + await ampForwardRequest(ctx) + expect(upstreamRequests[0]!.headers["authorization"]).toBe("Bearer amp-key-123") + expect(upstreamRequests[0]!.headers["x-amp-thread-id"]).toBe("T-abc") + expect(upstreamRequests[0]!.headers["x-amp-client-type"]).toBe("cli") + }) + + it("strips hop-by-hop headers", async () => { + const ctx = makeCtx({ + method: "GET", + path: "/api/thread-actors", + headers: { + "connection": "keep-alive", + "keep-alive": "timeout=5", + "transfer-encoding": "chunked", + "upgrade": "h2c", + "te": "trailers", + "trailer": "Expires", + "proxy-authorization": "Basic xxx", + "proxy-authenticate": "xxx", + }, + }) + await ampForwardRequest(ctx) + const fwd = upstreamRequests[0]!.headers + // NOTE: `connection` is intentionally omitted — Bun's fetch() re-adds + // `connection: keep-alive` regardless of what headers we pass. We verify + // the remaining hop-by-hop headers are stripped by our filter. + expect(fwd["keep-alive"]).toBeUndefined() + expect(fwd["transfer-encoding"]).toBeUndefined() + expect(fwd["upgrade"]).toBeUndefined() + expect(fwd["te"]).toBeUndefined() + expect(fwd["trailer"]).toBeUndefined() + expect(fwd["proxy-authorization"]).toBeUndefined() + expect(fwd["proxy-authenticate"]).toBeUndefined() + }) + + it("returns upstream status code", async () => { + upstreamResponse = { status: 404, headers: { "content-type": "text/plain" }, body: "missing" } + const ctx = makeCtx({ method: "GET", path: "/api/missing" }) + const res = await ampForwardRequest(ctx) + expect(res.status).toBe(404) + expect(await res.text()).toBe("missing") + }) + + it("returns upstream response headers", async () => { + upstreamResponse = { + status: 200, + headers: { "content-type": "application/json", "x-amp-server": "v2" }, + body: "{}", + } + const ctx = makeCtx({ method: "GET", path: "/api/thread-actors" }) + const res = await ampForwardRequest(ctx) + expect(res.headers.get("x-amp-server")).toBe("v2") + expect(res.headers.get("content-type")).toBe("application/json") + }) + + it("returns 503 when MERIDIAN_AMP_FORWARD_DISABLED=true", async () => { + process.env.MERIDIAN_AMP_FORWARD_DISABLED = "true" + try { + const ctx = makeCtx({ method: "GET", path: "/api/thread-actors" }) + const res = await ampForwardRequest(ctx) + expect(res.status).toBe(503) + expect(upstreamRequests).toHaveLength(0) + } finally { + delete process.env.MERIDIAN_AMP_FORWARD_DISABLED + } + }) + + it("uses default upstream https://ampcode.com when env not set", async () => { + delete process.env.AMP_UPSTREAM_URL + const { getAmpUpstreamUrl } = require("../proxy/passthrough/ampForwarder") + expect(getAmpUpstreamUrl()).toBe("https://ampcode.com") + process.env.AMP_UPSTREAM_URL = `http://127.0.0.1:${upstreamServer!.port}` + }) +}) + +describe("ampForwardRequest — streaming (SSE)", () => { + it("streams response body without buffering", async () => { + upstreamServer?.stop() + const chunks = ["data: a\n\n", "data: b\n\n", "data: c\n\n"] + upstreamServer = Bun.serve({ + port: 0, + async fetch(_req) { + const stream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder() + for (const chunk of chunks) { + controller.enqueue(enc.encode(chunk)) + await new Promise(r => setTimeout(r, 5)) + } + controller.close() + }, + }) + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }) + }, + }) + process.env.AMP_UPSTREAM_URL = `http://127.0.0.1:${upstreamServer.port}` + + const ctx = makeCtx({ method: "GET", path: "/api/thread-actors-stream" }) + const res = await ampForwardRequest(ctx) + expect(res.status).toBe(200) + expect(res.headers.get("content-type")).toBe("text/event-stream") + + const reader = res.body!.getReader() + const dec = new TextDecoder() + let received = "" + while (true) { + const { done, value } = await reader.read() + if (done) break + received += dec.decode(value) + } + expect(received).toBe(chunks.join("")) + }) +}) + +describe("ampForwardRequest — content-encoding handling", () => { + it("strips upstream Content-Encoding header so clients don't double-decode", async () => { + // Upstream returns actually-gzipped bytes. Bun fetch decodes them + // transparently inside the forwarder, but the Content-Encoding header + // remains. Forwarding that header verbatim would make the client try to + // gunzip an already-decoded body — the original `amp threads list` ZlibError. + upstreamServer?.stop() + const plainBody = '{"threads":[]}' + const gzipped = Bun.gzipSync(new TextEncoder().encode(plainBody)) + upstreamServer = Bun.serve({ + port: 0, + async fetch(_req) { + return new Response(gzipped, { + status: 200, + headers: { + "content-type": "application/json", + "content-encoding": "gzip", + "content-length": String(gzipped.byteLength), + }, + }) + }, + }) + process.env.AMP_UPSTREAM_URL = `http://127.0.0.1:${upstreamServer.port}` + + const ctx = makeCtx({ method: "GET", path: "/api/internal?listThreads" }) + const res = await ampForwardRequest(ctx) + expect(res.status).toBe(200) + expect(res.headers.get("content-encoding")).toBeNull() + expect(res.headers.get("content-length")).toBeNull() + expect(res.headers.get("content-type")).toBe("application/json") + const body = await res.text() + expect(body).toBe(plainBody) + }) +}) diff --git a/src/__tests__/amp-integration.test.ts b/src/__tests__/amp-integration.test.ts new file mode 100644 index 00000000..8d6ce6a9 --- /dev/null +++ b/src/__tests__/amp-integration.test.ts @@ -0,0 +1,239 @@ +/** + * HTTP-layer integration test for the Amp adapter. + * + * Verifies: + * - POST /api/provider/anthropic/v1/messages dispatches through the Amp adapter + * (not OpenCode) when no other detection signals are present. + * - GET /api/thread-actors is forwarded to AMP_UPSTREAM_URL. + */ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test" +import { detectAdapter } from "../proxy/adapters/detect" +import { assistantMessage } from "./helpers" + +let mockMessages: any[] = [] +let capturedQueryParams: any = null + +mock.module("@anthropic-ai/claude-agent-sdk", () => ({ + query: (params: any) => { + capturedQueryParams = params + return (async function* () { + for (const msg of mockMessages) yield msg + })() + }, + createSdkMcpServer: () => ({ type: "sdk", name: "amp", instance: {} }), +})) + +mock.module("../logger", () => ({ + claudeLog: () => {}, + withClaudeLogContext: (_ctx: any, fn: any) => fn(), +})) + +mock.module("../mcpTools", () => ({ + createOpencodeMcpServer: () => ({ type: "sdk", name: "opencode", instance: {} }), +})) + +const { createProxyServer, clearSessionCache } = await import("../proxy/server") + +function createTestApp() { + const { app } = createProxyServer({ port: 0, host: "127.0.0.1" }) + return app +} + +async function post(app: any, body: any, headers: Record = {}, path: string = "/v1/messages") { + return app.fetch(new Request(`http://localhost${path}`, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + })) +} + +async function get(app: any, path: string, headers: Record = {}) { + return app.fetch(new Request(`http://localhost${path}`, { + method: "GET", + headers, + })) +} + +describe("Amp adapter: detection", () => { + it("detects /api/provider/anthropic/v1/messages as amp", () => { + const ctx: any = { + req: { + path: "/api/provider/anthropic/v1/messages", + header: (name?: string) => { + if (name === undefined) return {} + return undefined + }, + }, + } + expect(detectAdapter(ctx).name).toBe("amp") + }) + + it("detects /api/provider/anthropic with trailing path as amp", () => { + const ctx: any = { + req: { + path: "/api/provider/anthropic/v1/chat/completions", + header: (name?: string) => { + if (name === undefined) return {} + return undefined + }, + }, + } + expect(detectAdapter(ctx).name).toBe("amp") + }) + + it("detects x-amp-client-type header as amp", () => { + const ctx: any = { + req: { + path: "/api/thread-actors", + header: (name?: string) => { + if (name === undefined) return { "x-amp-client-type": "cli" } + if (name.toLowerCase() === "x-amp-client-type") return "cli" + return undefined + }, + }, + } + expect(detectAdapter(ctx).name).toBe("amp") + }) + + it("detects any x-amp-* header as amp", () => { + const ctx: any = { + req: { + path: "/api/internal/config", + header: (name?: string) => { + if (name === undefined) return { "x-amp-version": "1.0" } + if (name.toLowerCase() === "x-amp-version") return "1.0" + return undefined + }, + }, + } + expect(detectAdapter(ctx).name).toBe("amp") + }) +}) + +describe("Amp adapter: HTTP routing", () => { + let savedPassthrough: string | undefined + + beforeEach(() => { + mockMessages = [assistantMessage([{ type: "text", text: "Done" }])] + capturedQueryParams = null + clearSessionCache() + savedPassthrough = process.env.MERIDIAN_PASSTHROUGH + process.env.MERIDIAN_PASSTHROUGH = "0" + }) + + afterEach(() => { + if (savedPassthrough !== undefined) process.env.MERIDIAN_PASSTHROUGH = savedPassthrough + else delete process.env.MERIDIAN_PASSTHROUGH + }) + + it("routes POST /api/provider/anthropic/v1/messages through amp adapter", async () => { + const app = createTestApp() + const body = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 1024, + messages: [{ role: "user", content: "Hello" }], + } + const res = await post(app, body, {}, "/api/provider/anthropic/v1/messages") + expect(res.status).toBe(200) + expect(capturedQueryParams).toBeDefined() + // Verify amp adapter was used by checking MCP server configuration + const mcpServers = capturedQueryParams.options.mcpServers + expect(Object.keys(mcpServers)).toContain("amp") + }) + + it("uses amp MCP server when request has x-amp-thread-id header", async () => { + const app = createTestApp() + const body = { + model: "claude-sonnet-4-5-20250929", + max_tokens: 1024, + messages: [{ role: "user", content: "Hello" }], + } + const res = await post(app, body, { "x-amp-thread-id": "thread-123" }) + expect(res.status).toBe(200) + const mcpServers = capturedQueryParams.options.mcpServers + expect(Object.keys(mcpServers)).toContain("amp") + }) +}) + +describe("Amp forwarder: integration", () => { + let upstream: ReturnType | undefined + let received: { method: string; path: string; body: string } | undefined + + beforeEach(() => { + received = undefined + upstream = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url) + const body = req.body ? await req.text() : "" + received = { method: req.method, path: url.pathname + url.search, body } + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }, + }) + process.env.AMP_UPSTREAM_URL = `http://127.0.0.1:${upstream.port}` + }) + + afterEach(() => { + upstream?.stop() + delete process.env.AMP_UPSTREAM_URL + }) + + it("forwarder reaches the configured upstream", async () => { + const { ampForwardRequest } = await import("../proxy/passthrough/ampForwarder") + const request = new Request("http://meridian.local/api/thread-actors?limit=5", { method: "GET" }) + const ctx: any = { + req: { + raw: request, + method: "GET", + path: "/api/thread-actors", + url: request.url, + header: () => ({}), + }, + } + const res = await ampForwardRequest(ctx) + expect(res.status).toBe(200) + expect(received?.path).toBe("/api/thread-actors?limit=5") + }) + + it("forwarder preserves query parameters", async () => { + const { ampForwardRequest } = await import("../proxy/passthrough/ampForwarder") + const request = new Request("http://meridian.local/api/attachments?file_id=abc&include=metadata", { method: "GET" }) + const ctx: any = { + req: { + raw: request, + method: "GET", + path: "/api/attachments", + url: request.url, + header: () => ({}), + }, + } + const res = await ampForwardRequest(ctx) + expect(res.status).toBe(200) + expect(received?.path).toBe("/api/attachments?file_id=abc&include=metadata") + }) + + it("forwarder forwards POST requests with body", async () => { + const { ampForwardRequest } = await import("../proxy/passthrough/ampForwarder") + const bodyText = JSON.stringify({ data: "test" }) + const request = new Request("http://meridian.local/api/telemetry", { + method: "POST", + body: bodyText, + headers: { "content-type": "application/json" }, + }) + const ctx: any = { + req: { + raw: request, + method: "POST", + path: "/api/telemetry", + url: request.url, + header: () => ({}), + }, + } + const res = await ampForwardRequest(ctx) + expect(res.status).toBe(200) + expect(received?.method).toBe("POST") + }) +}) diff --git a/src/proxy/adapters/amp.ts b/src/proxy/adapters/amp.ts new file mode 100644 index 00000000..20029db5 --- /dev/null +++ b/src/proxy/adapters/amp.ts @@ -0,0 +1,112 @@ +/** + * Amp agent adapter. + * + * Sourcegraph's Amp CLI (@sourcegraph/amp) sends standard Anthropic Messages API + * requests to ${AMP_URL}/api/provider/anthropic/v1/messages. With AMP_URL set to + * Meridian, Amp's Claude inference flows through this adapter while non-inference + * endpoints are forwarded upstream by ampForwarder. + * + * Key characteristics: + * - Wire path: /api/provider/anthropic/v1/messages (Anthropic Messages API) + * - Session header: x-amp-thread-id + * - Native client-side tool execution: passthrough mode is appropriate + * - Snake_case tool names: read_file, edit_file, create_file, bash, glob, grep, task, todo_write + * - Detection: path /api/provider/anthropic/ OR x-amp-client-* headers + */ + +import type { Context } from "hono" +import type { AgentAdapter } from "../adapter" +import { type FileChange, extractFileChangesFromBash } from "../fileChanges" +import { normalizeContent } from "../messages" +import { BLOCKED_BUILTIN_TOOLS, CLAUDE_CODE_ONLY_TOOLS } from "../tools" + +const AMP_MCP_SERVER_NAME = "amp" + +const AMP_ALLOWED_MCP_TOOLS: readonly string[] = [ + `mcp__${AMP_MCP_SERVER_NAME}__read`, + `mcp__${AMP_MCP_SERVER_NAME}__write`, + `mcp__${AMP_MCP_SERVER_NAME}__edit`, + `mcp__${AMP_MCP_SERVER_NAME}__bash`, + `mcp__${AMP_MCP_SERVER_NAME}__glob`, + `mcp__${AMP_MCP_SERVER_NAME}__grep`, +] + +export const ampAdapter: AgentAdapter = { + name: "amp", + + getSessionId(c: Context): string | undefined { + return c.req.header("x-amp-thread-id") + }, + + extractWorkingDirectory(_body: any): string | undefined { + return undefined + }, + + normalizeContent(content: any): string { + return normalizeContent(content) + }, + + getBlockedBuiltinTools(): readonly string[] { + return BLOCKED_BUILTIN_TOOLS + }, + + getAgentIncompatibleTools(): readonly string[] { + return CLAUDE_CODE_ONLY_TOOLS + }, + + getMcpServerName(): string { + return AMP_MCP_SERVER_NAME + }, + + getAllowedMcpTools(): readonly string[] { + return AMP_ALLOWED_MCP_TOOLS + }, + + buildSdkAgents(_body: any, _mcpToolNames: readonly string[]): Record { + return {} + }, + + buildSdkHooks(_body: any, _sdkAgents: Record): undefined { + return undefined + }, + + buildSystemContextAddendum(_body: any, _sdkAgents: Record): string { + return "" + }, + + usesPassthrough(): boolean { + return true + }, + + supportsThinking(): boolean { + return true + }, + + shouldTrackFileChanges(): boolean { + return false + }, + + /** + * NOTE: Amp-specific. Maps Amp's snake_case native tool names to file changes. + * Amp's writing tools: create_file (new), edit_file (mutate), bash (with redirects). + * Path field varies: prefer `path`, fall back to `file_path` / `filePath`. + */ + extractFileChangesFromToolUse(toolName: string, toolInput: unknown): FileChange[] { + const input = toolInput as Record | null | undefined + const filePath = input?.path ?? input?.file_path ?? input?.filePath + + if (toolName === "create_file" && filePath) { + return [{ operation: "wrote", path: String(filePath) }] + } + if (toolName === "edit_file" && filePath) { + return [{ operation: "edited", path: String(filePath) }] + } + if (toolName === "bash" && input?.command) { + return extractFileChangesFromBash(String(input.command)) + } + return [] + }, +} + +import { ampTransforms } from "../transforms/amp" +export { ampTransforms } diff --git a/src/proxy/adapters/detect.ts b/src/proxy/adapters/detect.ts index 3ad77209..f477d8c8 100644 --- a/src/proxy/adapters/detect.ts +++ b/src/proxy/adapters/detect.ts @@ -14,6 +14,7 @@ import { passthroughAdapter } from "./passthrough" import { piAdapter } from "./pi" import { forgeCodeAdapter } from "./forgecode" import { claudeCodeAdapter } from "./claudecode" +import { ampAdapter } from "./amp" const ADAPTER_MAP: Record = { opencode: openCodeAdapter, @@ -24,6 +25,7 @@ const ADAPTER_MAP: Record = { forgecode: forgeCodeAdapter, "claude-code": claudeCodeAdapter, claudecode: claudeCodeAdapter, + amp: ampAdapter, } const envDefault = process.env.MERIDIAN_DEFAULT_AGENT || "" @@ -67,6 +69,22 @@ export function detectAdapter(c: Context): AgentAdapter { return ADAPTER_MAP[agentOverride]! } + // Amp: path-prefix or any x-amp-* header + // Both inference (/api/provider/anthropic/) and forwarder paths (/api/thread-actors, + // /api/attachments, /api/telemetry, /api/internal/*) belong to Amp. Detect by: + // 1. /api/provider/anthropic prefix (inference path), OR + // 2. presence of any x-amp-* header (forwarder path or alternative routing) + const reqPath = c.req.path || "" + if (reqPath.startsWith("/api/provider/anthropic")) { + return ampAdapter + } + const allHeaders = c.req.header() || {} + for (const k of Object.keys(allHeaders)) { + if (k.toLowerCase().startsWith("x-amp-")) { + return ampAdapter + } + } + // OpenCode: plugin injects x-opencode-session; newer versions use x-session-affinity if (c.req.header("x-opencode-session") || c.req.header("x-session-affinity")) { return openCodeAdapter diff --git a/src/proxy/passthrough/ampForwarder.ts b/src/proxy/passthrough/ampForwarder.ts new file mode 100644 index 00000000..4409f07a --- /dev/null +++ b/src/proxy/passthrough/ampForwarder.ts @@ -0,0 +1,113 @@ +/** + * Amp HTTP forward proxy. + * + * Selectively passes through non-inference Amp traffic (threads, attachments, + * telemetry, login, internal config) to a configurable upstream — by default + * https://ampcode.com — so the entire Amp app remains usable when AMP_URL is + * pointed at Meridian. The inference path (/api/provider/anthropic/*) is NOT + * routed here; it goes through the regular /v1/messages handler. + */ + +import type { Context } from "hono" + +const DEFAULT_UPSTREAM = "https://ampcode.com" + +/** + * Hop-by-hop headers per RFC 7230 §6.1, plus proxy-* per common practice. + * These must not be forwarded across a proxy hop. + */ +const HOP_BY_HOP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]) + +export function getAmpUpstreamUrl(): string { + const v = process.env.AMP_UPSTREAM_URL?.trim() + if (v && v.length > 0) return v.replace(/\/+$/, "") + return DEFAULT_UPSTREAM +} + +function isForwardingDisabled(): boolean { + const v = (process.env.MERIDIAN_AMP_FORWARD_DISABLED ?? "").toLowerCase() + return v === "1" || v === "true" || v === "yes" +} + +function filterHeaders(src: Headers | Record): Headers { + const out = new Headers() + const entries = src instanceof Headers + ? Array.from(src.entries()) + : Object.entries(src) + for (const [k, v] of entries) { + const lk = k.toLowerCase() + if (HOP_BY_HOP.has(lk)) continue + // Drop Host so fetch sets it for the upstream automatically. + if (lk === "host") continue + // Drop content-length; let fetch recompute (body may be re-encoded). + if (lk === "content-length") continue + out.set(k, v) + } + return out +} + +/** + * Forward an inbound Hono request to the configured Amp upstream. + * Returns a Response whose body streams the upstream response body. + */ +export async function ampForwardRequest(c: Context): Promise { + if (isForwardingDisabled()) { + return new Response( + JSON.stringify({ error: "amp_forward_disabled", message: "MERIDIAN_AMP_FORWARD_DISABLED is set" }), + { status: 503, headers: { "content-type": "application/json" } }, + ) + } + + const upstreamBase = getAmpUpstreamUrl() + const inboundReq = c.req.raw // native Request + const inboundUrl = new URL(inboundReq.url) + const upstreamUrl = upstreamBase + inboundUrl.pathname + inboundUrl.search + + const method = inboundReq.method + const headers = filterHeaders(inboundReq.headers) + + const hasBody = method !== "GET" && method !== "HEAD" + // Use a typed extension rather than `as any` — `duplex` is required by + // undici/Bun when streaming a request body but isn't in the standard TS lib yet. + const init: RequestInit & { duplex?: "half" } = { + method, + headers, + redirect: "manual", + } + if (hasBody) { + init.body = inboundReq.body ?? undefined + init.duplex = "half" + } + + const upstreamRes = await fetch(upstreamUrl, init) + + // Bun/undici fetch transparently decodes gzip/deflate/br response bodies, but + // the original Content-Encoding header is preserved on the Response. If we + // forward that header verbatim the client tries to decode an already-decoded + // body and fails with ZlibError. Strip Content-Encoding (and Content-Length, + // which no longer matches the decoded body length) so the client treats the + // body as identity. + const respHeaders = new Headers() + upstreamRes.headers.forEach((v, k) => { + const lk = k.toLowerCase() + if (HOP_BY_HOP.has(lk)) return + if (lk === "content-encoding") return + if (lk === "content-length") return + respHeaders.set(k, v) + }) + + return new Response(upstreamRes.body, { + status: upstreamRes.status, + statusText: upstreamRes.statusText, + headers: respHeaders, + }) +} diff --git a/src/proxy/server.ts b/src/proxy/server.ts index 049c8059..b1106d7b 100644 --- a/src/proxy/server.ts +++ b/src/proxy/server.ts @@ -52,6 +52,7 @@ import { translateOpenAiToAnthropic, translateAnthropicToOpenAi, buildModelList, import { extractAdvisorModel, getLastUserMessage, stripAdvisorTools } from "./messages" import { requireAuth, authEnabled } from "./auth" import { detectAdapter } from "./adapters/detect" +import { ampForwardRequest } from "./passthrough/ampForwarder" import { buildQueryOptions, type QueryContext } from "./query" import { runTransformHook, buildPipeline, createRequestContext } from "./transform" import { getAdapterTransforms } from "./transforms/registry" @@ -2326,6 +2327,21 @@ export function createProxyServer(config: Partial = {}): ProxyServe app.post("/v1/messages", (c) => handleWithQueue(c, "/v1/messages")) app.post("/messages", (c) => handleWithQueue(c, "/messages")) + // Amp inference alias: same handler, different mount point. + // Amp's Anthropic SDK client posts to ${AMP_URL}/api/provider/anthropic/v1/messages. + app.post("/api/provider/anthropic/v1/messages", (c) => + handleWithQueue(c, "/api/provider/anthropic/v1/messages")) + app.post("/api/provider/anthropic/v1/messages/count_tokens", (c) => + handleWithQueue(c, "/api/provider/anthropic/v1/messages/count_tokens")) + + // Amp forwarder: catch-all for non-inference Amp endpoints + // (threads, attachments, telemetry, internal config, other-provider routes). + // Detection is handled inside the forwarder via path/header inspection in + // detectAdapter calls upstream of this; the route only fires for unmounted + // /api/* paths, so the inference aliases above take precedence. + app.all("/api/*", async (c) => ampForwardRequest(c)) + app.all("/.api/*", async (c) => ampForwardRequest(c)) + // Telemetry dashboard and API app.route("/telemetry", createTelemetryRoutes()) diff --git a/src/proxy/transforms/amp.ts b/src/proxy/transforms/amp.ts new file mode 100644 index 00000000..4c511ab8 --- /dev/null +++ b/src/proxy/transforms/amp.ts @@ -0,0 +1,39 @@ +import type { Transform, RequestContext } from "../transform" +import { extractFileChangesFromBash, type FileChange } from "../fileChanges" +import { BLOCKED_BUILTIN_TOOLS, CLAUDE_CODE_ONLY_TOOLS } from "../tools" + +const AMP_MCP_SERVER_NAME = "amp" +const AMP_ALLOWED_MCP_TOOLS: readonly string[] = [ + `mcp__${AMP_MCP_SERVER_NAME}__read`, + `mcp__${AMP_MCP_SERVER_NAME}__write`, + `mcp__${AMP_MCP_SERVER_NAME}__edit`, + `mcp__${AMP_MCP_SERVER_NAME}__bash`, + `mcp__${AMP_MCP_SERVER_NAME}__glob`, + `mcp__${AMP_MCP_SERVER_NAME}__grep`, +] + +export const ampTransforms: Transform[] = [ + { + name: "amp-core", + adapters: ["amp"], + onRequest(ctx: RequestContext): RequestContext { + const extractFileChangesFromToolUse = (toolName: string, toolInput: unknown): FileChange[] => { + const input = toolInput as Record | null | undefined + const filePath = input?.path ?? input?.file_path ?? input?.filePath + if (toolName === "create_file" && filePath) return [{ operation: "wrote", path: String(filePath) }] + if (toolName === "edit_file" && filePath) return [{ operation: "edited", path: String(filePath) }] + if (toolName === "bash" && input?.command) return extractFileChangesFromBash(String(input.command)) + return [] + } + + return { + ...ctx, + blockedTools: BLOCKED_BUILTIN_TOOLS, + incompatibleTools: CLAUDE_CODE_ONLY_TOOLS, + allowedMcpTools: AMP_ALLOWED_MCP_TOOLS, + sdkAgents: {}, + extractFileChangesFromToolUse, + } + }, + }, +] diff --git a/src/proxy/transforms/registry.ts b/src/proxy/transforms/registry.ts index 8a6e7a39..4a4124a3 100644 --- a/src/proxy/transforms/registry.ts +++ b/src/proxy/transforms/registry.ts @@ -5,6 +5,7 @@ import { droidTransforms } from "./droid" import { piTransforms } from "./pi" import { forgeCodeTransforms } from "./forgecode" import { passthroughTransforms } from "./passthrough" +import { ampTransforms } from "./amp" const ADAPTER_TRANSFORMS: Record = { opencode: openCodeTransforms, @@ -13,6 +14,7 @@ const ADAPTER_TRANSFORMS: Record = { pi: piTransforms, forgecode: forgeCodeTransforms, passthrough: passthroughTransforms, + amp: ampTransforms, } export function getAdapterTransforms(adapterName: string): readonly Transform[] {