Skip to content

Instantly share code, notes, and snippets.

@happytomatoe
Last active August 30, 2026 16:02
Show Gist options
  • Select an option

  • Save happytomatoe/b61e18920b221fac1a3ffd81441db061 to your computer and use it in GitHub Desktop.

Select an option

Save happytomatoe/b61e18920b221fac1a3ffd81441db061 to your computer and use it in GitHub Desktop.
Merge Gateway provider for pi coding agent — registers gateway.merge.dev's live model catalog as a pi provider (env: MERGE_GATEWAY_API_KEY, /login fallback)

Merge Gateway provider for pi coding agent

Registers Merge Gateway as a provider in pi coding agent. The model list is discovered live from GET /v1/models at load time, so the picker always reflects what Gateway currently serves — no hardcoded catalog to go stale.

Install

  1. Download the extension into pi's extensions directory:
curl -o ~/.pi/agent/extensions/merge-gateway.ts \
  https://gist.githubusercontent.com/happytomatoe/b61e18920b221fac1a3ffd81441db061/raw/merge-gateway.ts
  1. Provide your Gateway API key (mg_...), either:
# Option A — environment variable
export MERGE_GATEWAY_API_KEY=mg_...

or run /login inside pi and add the key there (stored in ~/.pi/agent/auth.json; the extension reads it as fallback).

  1. Restart pi (extensions load at startup), then pick a model:
/model
# filter: merge-gateway

The special default_routing entry hands requests to your Gateway routing policy instead of a specific model.

Notes

/**
* Merge Gateway provider for pi.
*
* Merge Gateway (https://gateway.merge.dev) routes every major LLM through one
* OpenAI-compatible endpoint. This extension discovers the live model catalog from
* GET /v1/models and registers it as a pi provider, so the picker always reflects
* what Gateway currently serves (no hardcoded model list to go stale).
*
* Usage:
* export MERGE_GATEWAY_API_KEY=mg_...
* Then pick a model via /model (filter on "merge-gateway"). The special
* "default_routing" entry hands requests to your Gateway routing policy.
*/
import { homedir } from "node:os";
import { join } from "node:path";
import { readFileSync } from "node:fs";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const BASE_URL = "https://api-gateway.merge.dev/v1/openai";
const CATALOG_URL = "https://api-gateway.merge.dev/v1/models";
const API_KEY_ENV = "MERGE_GATEWAY_API_KEY";
// Resolve the key from the environment, falling back to pi's credential store
// (~/.pi/agent/auth.json, populated by /login). The provider's apiKey in
// registerProvider stays "$MERGE_GATEWAY_API_KEY" so env-set keys work too; this
// only covers the catalog fetch, which needs a concrete key at load time.
function resolveApiKey(): string | undefined {
const env = process.env[API_KEY_ENV];
if (env) return env;
try {
const auth = JSON.parse(
readFileSync(join(homedir(), ".pi", "agent", "auth.json"), "utf8"),
) as Record<string, { key?: string }>;
return auth["merge-gateway"]?.key;
} catch {
return undefined;
}
}
type Vendor = {
context_window?: number;
max_output_tokens?: number;
capabilities?: {
input?: string[];
output?: string[];
supports_reasoning?: boolean;
};
pricing?: {
input_per_million?: number;
output_per_million?: number;
cache_read_per_million?: number;
cache_write_per_million?: number;
};
};
type CatalogModel = {
model: string;
display_name?: string;
vendors?: Record<string, Vendor>;
};
type CatalogResponse = {
data?: CatalogModel[];
has_more?: boolean;
next_cursor?: string;
};
type PiModel = {
id: string;
name: string;
reasoning: boolean;
input: ("text" | "image")[];
contextWindow: number;
maxTokens: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
};
const DEFAULT_ROUTING: PiModel = {
id: "default_routing",
name: "Merge Gateway (policy routing)",
reasoning: true,
input: ["text", "image"],
contextWindow: 128000,
maxTokens: 8192,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
};
function toPiModel(m: CatalogModel): PiModel | null {
const v = m.vendors && Object.values(m.vendors)[0];
if (!v) return null;
const caps = v.capabilities ?? {};
const outModes = caps.output ?? [];
// Keep only chat/completion models (skip image/audio/video gen, embeddings, tts).
if (!outModes.includes("text") && !outModes.includes("tool_use")) return null;
const input = (caps.input ?? []).filter(
(x): x is "text" | "image" => x === "text" || x === "image",
);
const pr = v.pricing ?? {};
return {
id: m.model,
name: m.display_name || m.model,
reasoning: Boolean(caps.supports_reasoning),
input: input.length ? input : ["text"],
contextWindow: v.context_window ?? 128000,
maxTokens: v.max_output_tokens ?? 8192,
cost: {
input: pr.input_per_million ?? 0,
output: pr.output_per_million ?? 0,
cacheRead: pr.cache_read_per_million ?? 0,
cacheWrite: pr.cache_write_per_million ?? 0,
},
};
}
async function fetchCatalog(apiKey: string, signal?: AbortSignal): Promise<PiModel[]> {
const models: PiModel[] = [];
let cursor: string | undefined;
do {
const url = new URL(CATALOG_URL);
if (cursor) url.searchParams.set("cursor", cursor);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: signal ?? AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`catalog status ${res.status}`);
const payload = (await res.json()) as CatalogResponse;
for (const m of payload.data ?? []) {
const pm = toPiModel(m);
if (pm) models.push(pm);
}
cursor = payload.has_more ? payload.next_cursor : undefined;
} while (cursor);
return models;
}
export default function (pi: ExtensionAPI) {
const apiKey = resolveApiKey();
pi.registerProvider("merge-gateway", {
name: "Merge Gateway",
baseUrl: BASE_URL,
// omp (oh-my-pi) does not interpolate $ENV syntax in registerProvider; pass
// the concrete key so both pi and omp work. Falls back to pi's auth store.
apiKey: apiKey ?? `$${API_KEY_ENV}`,
api: "openai-completions",
models: [DEFAULT_ROUTING],
// Live catalog discovery happens on model refresh, never during the factory —
// an awaited fetch here blocked pi startup by ~1.6s.
async refreshModels({ signal }: { signal: AbortSignal }) {
if (!apiKey) return [DEFAULT_ROUTING];
const discovered = await fetchCatalog(apiKey, signal);
return discovered.length ? [...discovered, DEFAULT_ROUTING] : [DEFAULT_ROUTING];
},
});
// Budget display: Gateway echoes credit balance / key limit headers on every
// response. Show remaining balance in the footer status after each turn.
pi.on("after_provider_response", (event, ctx) => {
const h = event.headers as Record<string, string | string[] | undefined>;
if (!h) return;
const pick = (...names: string[]) => {
for (const n of names) {
const v = h[n] ?? h[n.toLowerCase()];
if (v != null) return Array.isArray(v) ? v[0] : v;
}
return undefined;
};
const num = (v?: string) =>
v == null ? undefined : Number(v).toFixed(2).replace(/\.?0+$/, "");
const balance = num(pick("x-credit-balance-usd"));
const keyLeft = num(pick("x-key-limit-remaining-usd"));
const projLeft = num(pick("x-project-budget-remaining-usd"));
const warn = pick("x-budget-warning");
const parts: string[] = [];
if (balance != null) parts.push(`budget: $${balance}`);
if (keyLeft != null) parts.push(`api key: $${keyLeft}`);
if (projLeft != null) parts.push(`proj left: $${projLeft}`);
if (warn) parts.push(`⚠ ${warn}`);
if (parts.length) ctx.ui.setStatus("merge-gateway", parts.join(" · "));
});
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment