Files
magpie/internal/plugin/testdata/pi/index.ts
T
yetone 1baad0d2d4 plugin: pi packages are plugins as OpenCode's are — a pi coding agent package (its extensions) adds providers to magpie: their models, their sign-in, renewals and requests (owner: 我希望全面兼容 pi 插件!!!!!!!!!!) — a package is pi's when its package.json has a "pi" manifest, the "pi-package" keyword, or pi-coding-agent among its dependencies (IsPi, mirrored in host.js). Adding or updating one installs @earendil-works/pi-coding-agent beside it, and the plugin host loads every pi package through pi's own loader (pi.js: discoverAndLoadExtensions and ModelRuntime, with the credentials kept in plugin-auth.json), so an extension's registerCommand, registerTool and pi.on are taken and left unused, and its registerProvider calls become magpie providers. A provider's models keep their context, output, images and the reasoning levels their thinkingLevelMap allows; its OAuth login runs as pi runs it, each onPrompt asked in magpie's sign-in in turn, onAuth's page opened, a later prompt being the code pasted back; a provider with apiKey takes a key; magpie renews through refreshToken and saves the result. Requests arrive as Anthropic Messages and go to pi's stream: system and tools, images, tool results, thinking budgets and effort as the nearest level the model has (and the least it allows when it can't turn thinking off: Antigravity's gpt-oss refused requests without one), max_tokens held to the model's; the answer comes back as Messages SSE or JSON with thinking and its signature, redacted thinking, tool calls (their thought signatures kept for the next turn), usage, and a failure before anything is said as the vendor's status with Retry-After. The Plugins page's search also finds pi packages that bring a provider. Tried with the owner's real Antigravity account through pi-antigravity, next to the Grok OpenCode plugin in the same host: 14 models listed, Gemini streaming with usage, Claude Sonnet thinking with signatures, gpt-oss, tool round trips on gemini-3.8-flash, gemini-3.1-pro and claude-sonnet-4-6, a 400 and Antigravity's 429 passed on with their status. A Claude Code turn didn't get through: Antigravity answers 429 to any system prompt with Claude Code's billing header or its "Claude agent" line, through magpie's built-in Antigravity just the same. TestPiPackage (a fake pi package: models, an OAuth login asking twice with a wrong code then a right one, a key, streaming and JSON answers, thinking and tool signatures back, a 429, a renewal) and TestSearchFindsPiPackages pass. go vet, builds (darwin, GOOS=linux, GOOS=windows) and go test -tags nogui -count=1 ./... under a temp HOME pass but TestClaudeRunEndsWhenTheClientLeaves, TestProvidersAnswerWhileListsComeIn and TestSavedButSignedOutClaudeServed, timing tests that pass alone.
2026-10-03 19:12:19 +08:00

139 lines
6.0 KiB
TypeScript

// A pi extension as pi's own examples write one (custom-provider-anthropic):
// a provider with its own stream and an OAuth sign-in that asks as it goes,
// another signed in to with a key, and a command, a tool and an event
// handler, which magpie leaves unused. Its stream tells what pi handed it.
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai"
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
function stream(model, context, options) {
const s = createAssistantMessageEventStream()
const out = {
role: "assistant",
content: [],
api: model.api,
provider: model.provider,
model: model.id,
usage: { input: 11, output: 7, cacheRead: 3, cacheWrite: 2, totalTokens: 23, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
stopReason: "stop",
timestamp: Date.now(),
}
const msgs = context.messages
const last = msgs[msgs.length - 1]
const said = typeof last.content === "string" ? last.content : (last.content ?? []).map((c) => c.text ?? "").join("")
const system = msgs.filter((m) => m.role === "system")
queueMicrotask(() => {
s.push({ type: "start", partial: out })
if (said.includes("fail429")) {
options?.onResponse?.({ status: 429, headers: { "retry-after": "7" } })
out.stopReason = "error"
out.errorMessage = "429 Too Many Requests: slow down"
s.push({ type: "error", reason: "error", error: out })
s.end()
return
}
if (said.includes("think")) {
out.content.push({ type: "thinking", thinking: "", thinkingSignature: "" })
s.push({ type: "thinking_start", contentIndex: 0, partial: out })
out.content[0].thinking = "hmm"
s.push({ type: "thinking_delta", contentIndex: 0, delta: "hmm", partial: out })
out.content[0].thinkingSignature = "sig-1"
s.push({ type: "thinking_end", contentIndex: 0, content: "hmm", partial: out })
}
if (said.includes("tool")) {
const call = { type: "toolCall", id: "call_1", name: "lookup", arguments: { q: "x" }, thoughtSignature: "ts-1" }
out.content.push(call)
const i = out.content.length - 1
s.push({ type: "toolcall_start", contentIndex: i, partial: out })
s.push({ type: "toolcall_end", contentIndex: i, toolCall: call, partial: out })
out.stopReason = "toolUse"
s.push({ type: "done", reason: "toolUse", message: out })
s.end()
return
}
// an empty text first, as some vendors send one
out.content.push({ type: "text", text: "" })
s.push({ type: "text_start", contentIndex: out.content.length - 1, partial: out })
s.push({ type: "text_end", contentIndex: out.content.length - 1, content: "", partial: out })
const seen = {
apiKey: options?.apiKey,
reasoning: options?.reasoning ?? null,
maxTokens: options?.maxTokens,
system: system.map((m) => (typeof m.content === "string" ? m.content : m.content.map((c) => c.text).join(""))).join("|"),
tools: system.flatMap((m) => (m.toolsAdded ?? []).map((t) => t.name)),
roles: msgs.filter((m) => m.role !== "system").map((m) => m.role),
sigs: msgs.flatMap((m) => (m.role === "assistant" ? m.content.map((c) => c.thinkingSignature ?? c.thoughtSignature ?? "") : [])),
results: msgs.filter((m) => m.role === "toolResult").map((m) => m.toolName + ":" + m.content.map((c) => c.text ?? c.mimeType).join("")),
images: msgs.flatMap((m) => (Array.isArray(m.content) ? m.content.filter((c) => c.type === "image").map((c) => c.mimeType) : [])),
}
const text = JSON.stringify(seen)
out.content.push({ type: "text", text: "" })
const i = out.content.length - 1
s.push({ type: "text_start", contentIndex: i, partial: out })
for (const part of [text.slice(0, 10), text.slice(10)]) {
out.content[i].text += part
s.push({ type: "text_delta", contentIndex: i, delta: part, partial: out })
}
s.push({ type: "text_end", contentIndex: i, content: text, partial: out })
s.push({ type: "done", reason: "stop", message: out })
s.end()
})
return s
}
const models = [
{
id: "fp-think",
name: "FP Think",
reasoning: true,
thinkingLevelMap: { off: null, minimal: null, low: "low", medium: null, high: "high", xhigh: null, max: null },
input: ["text", "image"],
cost: { input: 1, output: 2, cacheRead: 0.1, cacheWrite: 1.25 },
contextWindow: 100000,
maxTokens: 8000,
},
{ id: "fp-plain", name: "FP Plain", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000, maxTokens: 500 },
]
export default function (pi: ExtensionAPI) {
pi.registerCommand("fakepi-hello", { description: "says hello", handler: async () => {} })
pi.registerTool({
name: "fakepi_tool",
label: "Fake",
description: "a tool for pi's own sessions",
parameters: { type: "object", properties: {} },
execute: async () => ({ content: [{ type: "text", text: "ok" }], details: {} }),
})
pi.on("session_start", () => {})
pi.registerProvider("fakepi", {
name: "FakePi",
baseUrl: "https://fakepi.invalid",
api: "fakepi-api",
models,
oauth: {
name: "FakePi Account",
async login(cb) {
const team = await cb.onPrompt({ message: "Team?", placeholder: "blue" })
cb.onAuth({ url: "https://fakepi.invalid/auth?team=" + team, instructions: "Sign in as " + team })
const code = await cb.onPrompt({ message: "Paste the code:" })
if (code !== "good") throw new Error("that code isn't right")
return { refresh: "r-" + team, access: "a-" + team, expires: Date.now() + 3600e3, team }
},
async refreshToken(c) {
return { ...c, access: "renewed-" + c.refresh, expires: Date.now() + 3600e3 }
},
getApiKey: (c) => c.access,
},
streamSimple: stream,
})
pi.registerProvider("fakepi-key", {
name: "FakePi Key",
baseUrl: "https://fakepi.invalid",
apiKey: "FAKEPI_KEY",
api: "fakepi-key-api",
models: [{ ...models[1], id: "fk-1", name: "FK 1" }],
streamSimple: stream,
})
}