Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pi/agents/test-driver.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Procedure:

1. Detect the test command first — check `package.json` scripts (test / test:unit / etc.), then common defaults (`bun test`, `npm test`, `yarn test`, `cargo test`, `go test ./...`, `pytest`). Run it and capture the failures.
2. For each failing test, read the test file and the code it exercises. Distinguish the failure kinds: assertion expectation drift, missing edge case, broken logic, environment/ordering issue, or a test that is simply wrong. Never change a test's expectations to force green without flagging it as a decision.
3. Fix the underlying cause with minimal edits that match the codebase's existing patterns. After each fix, rerun the targeted failing test first (e.g. `bun test <file>` or `bun test -t <name>`), then the full suite.
3. Fix the underlying cause with minimal edits that match the codebase's existing patterns. After each fix, rerun the targeted failing test first (e.g. `bun run test <file>` or `bun run test -t <name>`), then the full suite.
4. Keep the loop tight: one failure cluster at a time, don't batch half-understood edits. If a fix doesn't change the outcome, stop guessing and investigate — read the surrounding code and error stack before editing again.

Hard rules:
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Keep both Pi refresh-context shapes working:
- Pi 0.83: mutable `store` read/write API
- Pi 0.84: immutable `stored` snapshot plus generation-checked `publish`

On Pi 0.86.1+, Pi's built-in pi.dev `meta` catalog overlay refreshes before this extension through the same `models-store.json` entry. Keep persisted entries marked with `lastModified: 0` and `source: "pi-meta-oauth"`, restore only owned (or legacy unmarked) entries, and pin restored `baseUrl` to `https://api.meta.ai/v1`. `tests/model-runtime.test.ts` drives the real Pi `ModelRuntime` and skips on Pi versions without the built-in provider.

Hermetic OAuth and catalog tests live in `tests/meta.test.ts`. Failure-path and wire-shape tests (exact error messages, polling back-off, request shapes, catalog fallbacks, bundled model table) live in `tests/meta-failures.test.ts`.

## Prompt caching
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Honor login cancellation throughout device authorization, polling, and key minting, and stop polling when the device code expires.
- Validate malformed catalog and credential responses and return independent fallback/cache model metadata.
- Require Contributor literal `max` opt-in even when the catalog advertises it, and limit the Muse fingerprint to the bare direct endpoint.
- Keep the Meta catalog in `models-store.json` on Pi 0.86.1 and later, where Pi's built-in pi.dev `meta` overlay shares the store entry and refreshes first. The extension persists its catalog with `lastModified: 0` and a `source` marker so the overlay skips pi.dev while the Meta catalog is fresh. It ignores overlay-written entries on restore, republishes its last good catalog after a failed Meta refresh, and always restores `https://api.meta.ai/v1` as the base URL.
- Bound each device-authorization, token-poll, and key-mint request to 30 seconds, and token polls also to the device-code deadline, so a stalled connection fails with a timeout or expiry error instead of hanging login or Pi 0.83 refreshes.
- Report Pi's refresh timeout as `Meta token refresh timed out` instead of a cancellation.
- Accept only http(s) verification and setup URLs from Meta, normalized like Pi's built-in Meta login so control characters cannot reach the terminal.
- Apply the Responses cache hints only when the payload's model is the session's Meta model, so a mid-request model switch cannot rewrite another provider's request.
- Treat an empty or blank `META_API_KEY` or `MODEL_API_KEY` as unset when mirroring the two aliases.
- Point the README install command at the npm package or `main` instead of the stale v0.6.0 `meta-oauth-only` branch.

## [0.7.0] - 2026-09-26

Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ Meta Model API OAuth for [pi](https://pi.dev).
## Install

```bash
# OAuth-only branch
pi install git:github.com/BlockedPath/pi-meta-oauth@meta-oauth-only
pi install npm:pi-meta-oauth

# Or track main
pi install git:github.com/BlockedPath/pi-meta-oauth

# Or from a local checkout
pi install /absolute/path/to/pi-meta-oauth
Expand Down Expand Up @@ -94,6 +96,17 @@ count. Pi writes the cache during interactive or RPC startup, and again after
not itself trigger a network catalog refresh. The cached catalog is also used
when Pi starts without network access.

On Pi 0.86.1 and later, Pi's built-in pi.dev catalog overlay for `meta` shares
this store entry and refreshes before the extension. The extension persists its
catalog with `lastModified: 0` and `source: "pi-meta-oauth"`, so the overlay
skips pi.dev for 4 hours after a successful Meta refresh. On restore, entries
written by the overlay are ignored in favor of the bundled models, and the base
URL is always `https://api.meta.ai/v1`. If a Meta refresh fails after the
overlay wrote its entry, the extension republishes its last good catalog. If
pi.dev fails while the cached Meta entry is missing, older than 4 hours, or
written by an earlier release, Pi aborts that refresh before the Meta catalog is
fetched.

The bundled fallback uses Meta's nominal `1,048,576`-token context window. A
cached Muse Code 0.1.0/R708.1 catalog observed on 2026-08-06 reported a lower
effective limit of `1,007,997` for `muse-spark-1.2` and
Expand Down
10 changes: 9 additions & 1 deletion extensions/meta.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { META_PROVIDER_ID } from "../src/meta/constants.ts";
import { synchronizeMetaApiKeyAliases } from "../src/meta/environment.ts";
import { asRecord } from "../src/meta/http.ts";
import { createMetaProviderConfig } from "../src/meta/provider.ts";
import {
applyMetaModelHeaders,
Expand Down Expand Up @@ -38,7 +39,14 @@ export default function metaOAuthProvider(pi: ExtensionAPI): void {
synchronizeMetaApiKeyAliases(process.env);
pi.registerProvider(META_PROVIDER_ID, createMetaProviderConfig());
pi.on("before_provider_request", (event, ctx) => {
if (ctx.model?.provider !== META_PROVIDER_ID) return undefined;
// ctx.model is the session's current model, which can change mid-request;
// the Responses payload's own model id binds the hints to this request.
if (
ctx.model?.provider !== META_PROVIDER_ID ||
asRecord(event.payload)?.model !== ctx.model.id
) {
return undefined;
}
return applyMetaResponsesCacheHints(event.payload);
});
pi.on("before_provider_headers", (event, ctx) => {
Expand Down
2 changes: 2 additions & 0 deletions src/meta/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const DEVICE_TOKEN_URL = `${META_AUTH_BASE_URL}/oidc/device/token/`;
export const API_KEY_MINT_URL = "https://api.meta.ai/muse-code/key";
export const DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
export const API_KEY_REFRESH_INTERVAL_MS = 24 * 60 * 60 * 1000;
/** Bound each OAuth or mint request, matching Pi's built-in Meta flow. */
export const META_REQUEST_TIMEOUT_MS = 30_000;

/**
* Captured Muse CLI fingerprint. Preserve its bytes, including the platform
Expand Down
9 changes: 7 additions & 2 deletions src/meta/environment.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { META_ENV_VAR, MODEL_API_ENV_VAR } from "./constants.ts";
import type { MetaEnv } from "./types.ts";

/** Pi treats an empty $META_API_KEY as unset, and a blank one is never a usable key. */
function isMissing(value: string | undefined): boolean {
return !value?.trim();
}

/** Pi interpolates $META_API_KEY; Muse tooling may use MODEL_API_KEY. */
export function synchronizeMetaApiKeyAliases(env: MetaEnv): void {
if (env[META_ENV_VAR] === undefined && env[MODEL_API_ENV_VAR] !== undefined) {
if (isMissing(env[META_ENV_VAR]) && !isMissing(env[MODEL_API_ENV_VAR])) {
env[META_ENV_VAR] = env[MODEL_API_ENV_VAR];
}
if (env[MODEL_API_ENV_VAR] === undefined && env[META_ENV_VAR] !== undefined) {
if (isMissing(env[MODEL_API_ENV_VAR]) && !isMissing(env[META_ENV_VAR])) {
env[MODEL_API_ENV_VAR] = env[META_ENV_VAR];
}
}
52 changes: 42 additions & 10 deletions src/meta/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { META_REQUEST_TIMEOUT_MS } from "./constants.ts";
import type { Fetch } from "./types.ts";

/** A request outlived its own timeout while the caller's signal stayed live. */
export class RequestTimeoutError extends Error {
override name = "RequestTimeoutError";
}

/** Keep untrusted JSON as unknown until its fields have been validated. */
export function asRecord(value: unknown): Record<string, unknown> | undefined {
return value !== null && typeof value === "object" && !Array.isArray(value)
Expand Down Expand Up @@ -27,23 +33,49 @@ export function errorDetail(body: Record<string, unknown>): string | undefined {
return undefined;
}

/**
* Run one request under the caller's signal and its own timeout, so a stalled
* connection cannot hang a login or refresh. Only a timeout the caller did not
* cause becomes a RequestTimeoutError; caller cancellation passes through.
*/
export async function withRequestTimeout<T>(
signal: AbortSignal | undefined,
timeoutMs: number,
request: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const timeout = AbortSignal.timeout(timeoutMs);
try {
return await request(signal ? AbortSignal.any([signal, timeout]) : timeout);
} catch (error) {
if (timeout.aborted && !signal?.aborted) {
throw new RequestTimeoutError(`Request timed out after ${timeoutMs} ms`, {
cause: error,
});
}
throw error;
}
}

export async function postForm(
url: string,
fields: Record<string, string>,
fetchImpl: Fetch,
signal?: AbortSignal,
timeoutMs = META_REQUEST_TIMEOUT_MS,
): Promise<{ response: Response; body: Record<string, unknown> }> {
const response = await fetchImpl(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(fields),
redirect: "manual",
signal,
return withRequestTimeout(signal, timeoutMs, async (requestSignal) => {
const response = await fetchImpl(url, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(fields),
redirect: "manual",
signal: requestSignal,
});
return { response, body: await responseBody(response) };
});
return { response, body: await responseBody(response) };
}

export function delay(milliseconds: number): Promise<void> {
Expand Down
63 changes: 58 additions & 5 deletions src/meta/model-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ import {
} from "./models.ts";
import type { Fetch, MetaProviderModel } from "./types.ts";

/**
* Pi >=0.86.1 refreshes its built-in pi.dev `meta` overlay before this extension, through the same
* models-store key, so persisted entries carry a provenance marker.
*/
const STORE_SOURCE = "pi-meta-oauth";

type OwnedStoreEntry = ModelsStoreEntry & { source: typeof STORE_SOURCE };

interface LegacyCatalogStore {
read(): Promise<unknown>;
write(entry: ModelsStoreEntry): Promise<void>;
Expand All @@ -34,19 +42,60 @@ interface CompatibleRefreshContext {
}): Promise<boolean>;
}

/** Accept marked entries and unmarked ones from older releases; pi.dev overlays set lastModified whenever they persist a catalog. */
function isOwnedEntry(value: unknown): boolean {
const entry = asRecord(value);
if (!entry) return false;
if (entry.source === STORE_SOURCE) return true;
return (
entry.source === undefined &&
entry.lastModified === undefined &&
entry.etag === undefined
);
}

async function cachedOrFallbackModels(
context: CompatibleRefreshContext,
): Promise<MetaProviderModel[]> {
try {
const stored = context.stored ?? (await context.store?.read());
const models = restoreProviderModels(asRecord(stored)?.models);
if (models.length > 0) return models;
if (isOwnedEntry(stored)) {
const models = restoreProviderModels(asRecord(stored)?.models);
if (models.length > 0) return models;
}
} catch {
// Persistence is best-effort; an unreadable cache must not disable the provider.
}
return fallbackModels();
}

/** Undo a pi.dev overlay write from this refresh so the last good Meta catalog stays persisted. */
async function republishOwnedEntry(
context: CompatibleRefreshContext,
): Promise<void> {
const stored = context.stored;
if (context.signal?.aborted || !context.publish || !stored) return;
if (!isOwnedEntry(stored)) return;
// Keep the original checkedAt: this refresh did not validate the catalog.
const entry: OwnedStoreEntry = {
...stored,
lastModified: 0,
source: STORE_SOURCE,
};
try {
await context.publish({ persist: entry });
} catch {
// The restored catalog remains usable when its persistence fails.
}
}

async function restoreAfterFailedRefresh(
context: CompatibleRefreshContext,
): Promise<MetaProviderModel[]> {
await republishOwnedEntry(context);
return cachedOrFallbackModels(context);
}

function modelsForStore(
models: MetaProviderModel[],
): Model<"openai-responses">[] {
Expand All @@ -65,9 +114,12 @@ async function persistModels(
context: CompatibleRefreshContext,
models: MetaProviderModel[],
): Promise<void> {
const entry: ModelsStoreEntry = {
const entry: OwnedStoreEntry = {
models: modelsForStore(models),
checkedAt: Date.now(),
// Pi's overlay reads lastModified 0 as "no pi.dev catalog" and skips pi.dev while this entry is fresh.
lastModified: 0,
source: STORE_SOURCE,
};
if (context.publish) {
// A false result means this generation was superseded; never bypass that check with a legacy write.
Expand Down Expand Up @@ -113,7 +165,8 @@ export async function refreshMetaModels(
);
}
const models = toProviderModels(body);
if (models.length === 0) return cachedOrFallbackModels(compatibleContext);
if (models.length === 0)
return restoreAfterFailedRefresh(compatibleContext);
if (!context.signal?.aborted) {
try {
await persistModels(compatibleContext, models);
Expand All @@ -125,6 +178,6 @@ export async function refreshMetaModels(
} catch (error) {
// An in-flight cancellation belongs to the host; do not disguise it as a successful refresh.
if (context.signal?.aborted) throw error;
return cachedOrFallbackModels(compatibleContext);
return restoreAfterFailedRefresh(compatibleContext);
}
}
3 changes: 2 additions & 1 deletion src/meta/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,8 @@ export function restoreProviderModels(value: unknown): MetaProviderModel[] {
...fallback,
name: nonemptyString(entry.name) ?? fallback.name,
api: "openai-responses",
baseUrl: nonemptyString(entry.baseUrl) ?? META_API_BASE_URL,
// The extension only persists the direct endpoint; a cached URL must never redirect the key.
baseUrl: META_API_BASE_URL,
reasoning:
typeof entry.reasoning === "boolean"
? entry.reasoning
Expand Down
Loading
Loading