Files
PaperClipAI/packages/shared/src/ai-provider-routing.ts
T
DottaandPaperclip 54cc522e86 feat(runner): add gated native Antigravity ACP execution (#18188)
## Thinking Path

> - Paperclip manages agents and their work.
> - Native Runner must bind each provider turn to the selected task,
account, model, and instructions.
> - Antigravity needs native ACP admission and physical process
ownership across continuation.
> - A saved conversation must not reuse an old process owner or silently
switch its effective model.
> - This pull request adds the native candidate behind exact operator
admission.
> - The default release gate remains closed while qualification is
pending.

## Linked Issues or Issue Description

Builds on merged #18187. Product account setup and the candidate
producer are in dependent #18189. The repeatable browser journey is in
#18190. Refs: #12503, #14938; those proposals use legacy CLI adapters.

**Agent or provider**

Google Antigravity ACP 1.4.3.

**Why this adapter is useful**

Users need to set up an Antigravity agent and run real work through
their managed Google subscription.

**How the agent is invoked**

Native Paperclip Runner uses the pinned Antigravity ACP runtime. Exact
operator admission is required while qualification remains pending.

## What Changed

- Add native Antigravity ACP model and instruction admission,
selected-account homes, and fenced refresh ownership.
- Carry exact candidate authority from the control plane through Runner,
Rust, the sidecar, and each current run. Clamp daemon lifetime to the
absolute permission expiry, including replacement owners.
- Preserve a rotated OAuth refresh token before later identity
verification can fail, including failed or cancelled startup admission.
Make private lease-close retries safe.
- Bound native probe retirement and retry the exact original owner; do
not delete credential homes before physical proof.
- Preserve Gemini API-key recovery when shared Google subscription
capability is added.
- Resume the same tagged native conversation with fresh per-turn process
ownership and physical native retirement.
- Include native manifests in isolated Rust image stages, release cache
keys, and dev rebuild detection. The workflow changes only extend
existing cache input lists.
- Keep current master provider behavior and append Grok 13, Pi 35, and
Copilot 84 input identities. Preserve prior identities, including saved
Copilot 82 and 83 runs.

## Verification

- Runner TypeScript main checks, profile generators, Rust formatting,
and offline workspace checks pass. The native backend integration target
passes 28 tests.
- The complete transport and cleanup group passes 387 tests without
unhandled rejections. These checks use local scripted children and
actual process and port ownership.
- Saved-profile decoding, native-execution parsing, installation, and
selected-credential controls pass 208 focused tests. All 109
pre-existing profile identity files remain byte-for-byte unchanged.
Copilot, Grok, and shared TypeScript/Rust generation checks pass.
- The final recovery fixture passes all seven cases. After the master
rebase, the combined recovery and owned-Node-runtime group passes all 10
cases. Each expiry case sets one 45-second absolute grant before
startup. It includes the deliberate six-second cold delay, four existing
eight-second command budgets, and active-turn headroom. The test proves
an active turn before expiry and then checks the daemon, owned process
group, child processes, and all three native port fences. The 60-second
test deadline does not renew provider authority.
- The consolidated model-discovery and pending-release group passes 22
tests. Google credential compatibility and auth recovery pass 25 tests,
including existing Gemini API-key recovery. The full product settings
test group remains intact in #18189.
- Earlier failures remain in their original CI runs and local records.
They exposed the Copilot 82 decoder gap, an expired five-second fixture
bootstrap ticket, and a fixture grant consumed before activation on
loaded CI. Each repair has a separate result. Production ticket limits
and admission behavior are unchanged by the final test commits.
- An unrelated decision-expiry check failed in [the earlier CI
run](https://github.com/paperclipai/paperclip/actions/runs/40941986698/job/123688668997).
Its owning code is unchanged from the base. The unchanged suite passes
20 tests on isolated PostgreSQL. A controlled database rollback
reproduces the earlier wake: clean startup schedules the 120-second
decision expiry, while rollback reconciliation leaves a 30-second
continuation-coalescing wake. The decision stays open, with no wake
delivery or error. The fresh final-head server shard passes; the failed
result is preserved.
- Final native commit `ec6bc7e4d77f5ae0cc4946dbba6c662e95a87f5b` passed
`ci / verify` and `ci / e2e`. Fresh Greptile and Paperclip reviews both
score 5/5, with no open findings. Antigravity retains the recorded
native `.5` closure and exact low-model profile. This PR does not claim
global or cross-platform qualification.

## Risks

- The new transport carries private managed credentials. Exact
ownership, account generation, model, instructions, and native
retirement are fail-closed.
- Only local macOS ARM64 has a verified complete Antigravity runtime.
Default Antigravity task execution remains disabled without current
trusted operator admission. The Google server sign-in lane and
production candidate producer ship in #18189; this native slice does not
expose the subscription catalog choice.
- Shared launch inputs change provider identities. New identities are
append-only; existing recorded identities remain available.

## Model Used

OpenAI Codex (GPT-6). The host did not expose the exact model revision
or context limit. The model used tools for code edits, tests, and
review.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-29 16:36:12 -05:00

162 lines
5.5 KiB
TypeScript

import { z } from "zod";
/** Value-free routing stored on the connection, never supplied by an agent binding. */
export const aiProviderRoutingSchema = z
.object({
kind: z.enum(["openrouter", "bedrock", "gateway", "local"]),
protocol: z.enum(["responses", "messages", "chat", "bedrock"]),
baseUrl: z.string().trim().max(2048).optional(),
auth: z
.enum(["bearer", "api_key", "none"])
.default("bearer"),
region: z
.string()
.regex(/^[a-z]{2}(?:-[a-z]+)+-\d$/)
.optional(),
models: z
.array(
z
.object({
id: z.string().trim().min(1).max(256),
label: z.string().trim().max(160).optional(),
})
.strict(),
)
.max(200)
.default([]),
})
.strict()
.superRefine((route, ctx) => {
const invalid = (message: string) =>
ctx.addIssue({ code: "custom", message });
if (route.kind === "bedrock") {
if (
route.protocol !== "bedrock" ||
!route.region ||
route.auth !== "bearer" ||
route.baseUrl
)
invalid(
"Bedrock requires a region and a Bedrock API key.",
);
} else if (
route.protocol === "bedrock" ||
route.region
)
invalid("AWS settings require Bedrock.");
if (
route.kind === "openrouter" &&
(route.baseUrl || route.auth !== "bearer")
)
invalid("OpenRouter uses its official endpoint and an API key.");
if (route.auth === "api_key" && route.protocol !== "messages")
invalid("API-key header authentication requires Anthropic Messages.");
if (route.kind === "gateway" || route.kind === "local") {
try {
const url = new URL(route.baseUrl ?? "");
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(
url.hostname,
);
if (
url.username ||
url.password ||
url.search ||
url.hash ||
(url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
)
throw new Error();
if (route.kind === "local" && !loopback)
invalid("A local endpoint must use localhost or a loopback address.");
} catch {
invalid(
"Enter an HTTPS base URL without credentials, query parameters, or a fragment. Local endpoints may use HTTP.",
);
}
}
});
export type AiProviderRouting = z.infer<typeof aiProviderRoutingSchema>;
export function aiRoutingHarness(
adapter: string,
provider?: unknown,
acpxAgent?: unknown,
): string {
if (adapter !== "paperclip_runner") return adapter;
if (provider === "acpx" && acpxAgent === "copilot") return "copilot_runtime";
if (provider === "acpx" && acpxAgent === "antigravity") return "antigravity_runtime";
const runner =
provider === "acpx" && ["claude", "grok", "pi"].includes(String(acpxAgent))
? acpxAgent
: provider;
return (
(
{
claude: "claude_local",
codex: "codex_local",
opencode: "opencode_local",
grok: "grok_local",
pi: "pi_local",
} as Record<string, string>
)[String(runner)] ?? "unsupported"
);
}
export function isAiRoutingCompatible(
route: AiProviderRouting,
harness: string,
): boolean {
if (route.kind === "bedrock") return harness === "claude_local";
if (route.kind === "openrouter")
return [
"claude_local",
"codex_local",
"opencode_local",
"pi_local",
"hermes_local",
].includes(harness);
if (route.protocol === "responses") return harness === "codex_local";
if (route.protocol === "messages") return harness === "claude_local";
return ["opencode_local", "hermes_local"].includes(harness);
}
export function aiRoutingBaseUrl(
route: AiProviderRouting,
harness: string,
): string {
return route.kind === "openrouter"
? `https://openrouter.ai/api${harness === "claude_local" ? "" : "/v1"}`
: (route.baseUrl ?? "").replace(/\/+$/, "");
}
export function aiRoutingModel(
route: AiProviderRouting,
harness: string,
model: string,
): string {
if (!model || (harness !== "opencode_local" && !(harness === "pi_local" && route.kind === "openrouter"))) return model;
const prefix = route.kind === "openrouter" ? "openrouter" : "paperclip";
return model.startsWith(`${prefix}/`) ? model : `${prefix}/${model}`;
}
/** Catalog identity is derived from routing, including accounts saved before these rows existed. */
export function aiConnectionCatalogSlug(provider: string, routing?: Pick<AiProviderRouting, "kind" | "protocol">): string {
if (routing?.kind === "gateway") {
return routing.protocol === "messages" ? "messages-api"
: routing.protocol === "chat" ? "chat-completions-api" : "responses-api";
}
return routing?.kind ?? provider;
}
/** Direct catalog setup presets; legacy gateway links still open their existing setup. */
export function aiProviderSetupPreset(source: string | null): {
provider: "openrouter" | "bedrock" | "gateway" | "local" | "google";
protocol?: AiProviderRouting["protocol"];
label?: string;
} | undefined {
switch (source) {
case "responses-api": return { provider: "gateway", protocol: "responses", label: "Responses API" };
case "messages-api": return { provider: "gateway", protocol: "messages", label: "Messages API" };
case "chat-completions-api": return { provider: "gateway", protocol: "chat", label: "Chat Completions API" };
case "google-ai": return { provider: "google" };
case "google": case "openrouter": case "bedrock": case "gateway": case "local": return { provider: source };
default: return undefined;
}
}