Skip to content

Commit f32b43a

Browse files
Merge pull request #1546 from corbitsdev/cl-9866-meta-provider-on-main
feat(provider): add meta device oauth and api key
2 parents 2576ae5 + 37bff36 commit f32b43a

48 files changed

Lines changed: 2102 additions & 78 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎bun.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@
8989
"dependencies": {
9090
"@anthropic-ai/sandbox-runtime": "0.0.79",
9191
"@corbits/codex-provider": "0.1.3",
92+
"@corbits/meta-provider": "0.1.1",
9293
"@corbits/oauth-core": "0.3.2",
9394
"@corbits/openai-responses": "0.2.2",
9495
"@corbits/xai-provider": "0.1.3",

‎packages/first-class-providers/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
FIRST_CLASS_PROVIDERS,
3+
META_API_KEY_PROVIDER_ID,
34
OPENAI_API_BASE_URL,
45
OPENAI_API_MAX_COMPLETION_TOKENS_MODELS,
56
firstClassPathAsProvider,

‎packages/first-class-providers/src/providers.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ describe("FIRST_CLASS_PROVIDERS", () => {
1010
expect(FIRST_CLASS_PROVIDERS.map((p) => p.id)).toEqual([
1111
"openai",
1212
"xai",
13+
"meta",
1314
"opencode-go",
1415
"zen",
1516
"zai",
@@ -94,6 +95,26 @@ describe("FIRST_CLASS_PROVIDERS", () => {
9495
}
9596
});
9697

98+
test("Meta is a chooser with Sign In and API key paths", () => {
99+
const meta = firstClassProviderById("meta");
100+
expect(meta?.auth).toBe("chooser");
101+
expect(meta?.paths?.map((p) => p.id)).toEqual(["signin", "api"]);
102+
103+
const signin = meta?.paths?.find((p) => p.id === "signin");
104+
expect(signin?.auth).toBe("oauth");
105+
expect(signin?.oauth).toBe("meta");
106+
expect(signin?.providerId).toBe("meta");
107+
108+
const api = meta?.paths?.find((p) => p.id === "api");
109+
expect(api?.auth).toBe("api-key");
110+
expect(api?.providerId).toBe("meta-api");
111+
expect(api?.baseURL).toBe("https://api.meta.ai/v1");
112+
expect(api?.defaultModel).toBe("muse-wonder-1.0");
113+
expect((api?.models ?? []).length).toBeGreaterThan(0);
114+
expect(api?.models).toContain(api?.defaultModel);
115+
expect(api?.authHint).toContain("LLM|");
116+
});
117+
97118
test("API-key providers ship baseURL, models, and defaultModel", () => {
98119
for (const def of FIRST_CLASS_PROVIDERS) {
99120
if (def.auth !== "api-key") continue;

‎packages/first-class-providers/src/providers.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@ import {
1313
ZEN_MODEL_IDS,
1414
ZEN_PROVIDER_ID,
1515
} from "../../zen/src/index.js";
16+
import { META_BASE_URL, META_DEFAULT_MODELS } from "@corbits/meta-provider";
1617
import type { FirstClassProviderDef } from "./types.js";
1718

19+
/** Settings id for a pasted Meta API key. OAuth profiles stay under `meta/<slug>`. */
20+
export const META_API_KEY_PROVIDER_ID = "meta-api";
21+
1822
const OPENAI_API_MODELS = [
1923
"gpt-6-astra",
2024
"gpt-5.4",
@@ -88,6 +92,30 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [
8892
auth: "oauth",
8993
oauth: "xai",
9094
},
95+
{
96+
id: "meta",
97+
label: "Meta",
98+
auth: "chooser",
99+
paths: [
100+
{
101+
id: "signin",
102+
label: "Sign In",
103+
auth: "oauth",
104+
oauth: "meta",
105+
providerId: "meta",
106+
},
107+
{
108+
id: "api",
109+
label: "Meta API — API key",
110+
auth: "api-key",
111+
baseURL: META_BASE_URL,
112+
models: META_DEFAULT_MODELS,
113+
defaultModel: META_DEFAULT_MODELS[0],
114+
authHint: "Paste your Meta API key (LLM|...)",
115+
providerId: META_API_KEY_PROVIDER_ID,
116+
},
117+
],
118+
},
91119
{
92120
id: OPENCODE_GO_PROVIDER_ID,
93121
label: OPENCODE_GO_DISPLAY_NAME,

‎packages/first-class-providers/src/types.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export type FirstClassAuthKind =
55
| "chooser"
66
| "custom";
77

8-
export type FirstClassOAuthProvider = "codex" | "xai";
8+
export type FirstClassOAuthProvider = "codex" | "xai" | "meta";
99

1010
export type FirstClassBillingProduct = "subscription" | "credits";
1111

‎src/auth/credential-surface-coverage.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
credentialFileDescriptors,
1010
} from "./credential-surface.js";
1111
import { MCP_AUTH_DIRNAME } from "../mcp/auth-store.js";
12+
import { META_AUTH_FILENAME } from "./meta/store.js";
1213
import { XAI_AUTH_FILENAME } from "./xai/store.js";
1314

1415
const here = dirname(fileURLToPath(import.meta.url));
@@ -18,6 +19,7 @@ const here = dirname(fileURLToPath(import.meta.url));
1819
// scan excludes, so fixture names can never become denylist patterns.
1920
const STORE_FILES = [
2021
join(here, "codex", "store.ts"),
22+
join(here, "meta", "store.ts"),
2123
join(here, "xai", "store.ts"),
2224
join(here, "..", "mcp", "auth-store.ts"),
2325
];
@@ -62,7 +64,7 @@ describe("CL-7789 credential-surface coverage", () => {
6264
}
6365
}
6466
expect([...seen].sort()).toEqual(
65-
[CODEX_AUTH_FILENAME, XAI_AUTH_FILENAME].sort(),
67+
[CODEX_AUTH_FILENAME, META_AUTH_FILENAME, XAI_AUTH_FILENAME].sort(),
6668
);
6769
});
6870

‎src/auth/credential-surface.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface CredentialDirDescriptor {
1717
export const credentialFileDescriptors: CredentialFileDescriptor[] = [
1818
{ settingsDirName: SETTINGS_DIR_NAME, filename: "codex-auth.json" },
1919
{ settingsDirName: SETTINGS_DIR_NAME, filename: "xai-auth.json" },
20+
{ settingsDirName: SETTINGS_DIR_NAME, filename: "meta-auth.json" },
2021
];
2122

2223
export const credentialDirDescriptors: CredentialDirDescriptor[] = [

‎src/auth/meta/constants.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { META_BASE_URL, META_DEFAULT_MODELS } from "@corbits/meta-provider";
2+
3+
export { META_BASE_URL, META_DEFAULT_MODELS };
4+
5+
// Minted keys last ~24h; skew stays inside that to avoid an expiry race.
6+
export const META_REFRESH_SKEW_MS = 5 * 60 * 1000;

‎src/auth/meta/login.test.ts‎

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { test, expect, describe } from "bun:test";
2+
import { mkdtemp, rm } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { startMetaLogin } from "./login.js";
6+
import { loadMetaProfile } from "../../config/oauth-stores.js";
7+
import { REJECTED_META_VERIFICATION_URI } from "./verification-uri.js";
8+
9+
const AUTH_URL = "https://auth.meta.com/oidc/device/authorization/";
10+
const TOKEN_URL = "https://auth.meta.com/oidc/device/token/";
11+
const MINT_URL = "https://api.meta.ai/muse-code/key";
12+
13+
function jsonResponse(body: unknown, status = 200): Response {
14+
return new Response(JSON.stringify(body), {
15+
status,
16+
headers: { "content-type": "application/json" },
17+
});
18+
}
19+
20+
function noopSleep(): Promise<void> {
21+
return Promise.resolve();
22+
}
23+
24+
async function withHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
25+
const home = await mkdtemp(join(tmpdir(), "meta-login-"));
26+
try {
27+
return await fn(home);
28+
} finally {
29+
await rm(home, { recursive: true, force: true });
30+
}
31+
}
32+
33+
describe("startMetaLogin (device flow)", () => {
34+
test("authorize → poll pending → complete → mint → saved profile", async () => {
35+
await withHome(async (home) => {
36+
const requests: string[] = [];
37+
const fetchImpl = (async (input: RequestInfo | URL) => {
38+
const url = String(input);
39+
requests.push(url);
40+
if (url === AUTH_URL) {
41+
return jsonResponse({
42+
device_code: "dc-1",
43+
user_code: "ABCD-1234",
44+
verification_uri_complete:
45+
"https://auth.meta.com/device?code=ABCD-1234",
46+
interval: 1,
47+
expires_in: 1800,
48+
});
49+
}
50+
if (url === TOKEN_URL) {
51+
return jsonResponse({
52+
access_token: "identity-token",
53+
token_type: "bearer",
54+
});
55+
}
56+
if (url === MINT_URL) {
57+
return jsonResponse({ api_key: "LLM|minted" });
58+
}
59+
throw new Error(`unexpected fetch ${url}`);
60+
}) as unknown as typeof fetch;
61+
62+
let notified: { verificationUri: string; userCode: string } | undefined;
63+
const controller = new AbortController();
64+
const handle = startMetaLogin({
65+
profile: "work",
66+
signal: controller.signal,
67+
home,
68+
fetchImpl,
69+
sleep: noopSleep,
70+
notify: (event) => {
71+
notified = {
72+
verificationUri: event.verificationUri,
73+
userCode: event.userCode,
74+
};
75+
},
76+
});
77+
78+
const staged = await handle.completed;
79+
expect(notified).toEqual({
80+
verificationUri: "https://auth.meta.com/device?code=ABCD-1234",
81+
userCode: "ABCD-1234",
82+
});
83+
expect(staged.profile.name).toBe("work");
84+
expect(staged.profile.tokens.access).toBe("LLM|minted");
85+
expect(staged.profile.tokens.refresh).toBe("identity-token");
86+
87+
await staged.commit();
88+
const saved = await loadMetaProfile("work", home);
89+
expect(saved?.tokens.access).toBe("LLM|minted");
90+
expect(saved?.tokens.refresh).toBe("identity-token");
91+
expect(requests).toEqual([AUTH_URL, TOKEN_URL, MINT_URL]);
92+
});
93+
});
94+
95+
test("does not forward an http or non-Meta verification URI", async () => {
96+
const fetchImpl = (async (input: RequestInfo | URL) => {
97+
const url = String(input);
98+
if (url === AUTH_URL) {
99+
return jsonResponse({
100+
device_code: "dc-1",
101+
user_code: "ABCD-1234",
102+
verification_uri_complete: "http://evil.example/device?code=ABCD",
103+
interval: 1,
104+
expires_in: 1800,
105+
});
106+
}
107+
throw new Error(`unexpected fetch ${url}`);
108+
}) as unknown as typeof fetch;
109+
110+
let notified = false;
111+
const controller = new AbortController();
112+
const handle = startMetaLogin({
113+
profile: "work",
114+
signal: controller.signal,
115+
fetchImpl,
116+
sleep: noopSleep,
117+
notify: () => {
118+
notified = true;
119+
},
120+
});
121+
await expect(handle.completed).rejects.toThrow(
122+
REJECTED_META_VERIFICATION_URI,
123+
);
124+
expect(notified).toBe(false);
125+
});
126+
127+
test("abort rejects with the cancelled-login message", async () => {
128+
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
129+
const url = String(input);
130+
if (url === AUTH_URL) {
131+
return jsonResponse({
132+
device_code: "dc-1",
133+
user_code: "ABCD-1234",
134+
verification_uri_complete: "https://auth.meta.com/device",
135+
interval: 1,
136+
expires_in: 1800,
137+
});
138+
}
139+
return new Promise<Response>((_resolve, reject) => {
140+
init?.signal?.addEventListener(
141+
"abort",
142+
() => reject(new DOMException("aborted", "AbortError")),
143+
{ once: true },
144+
);
145+
});
146+
}) as unknown as typeof fetch;
147+
148+
const controller = new AbortController();
149+
const handle = startMetaLogin({
150+
profile: "work",
151+
signal: controller.signal,
152+
fetchImpl,
153+
sleep: noopSleep,
154+
});
155+
156+
const pending = handle.completed.catch((err: unknown) => err);
157+
setTimeout(() => controller.abort(), 0);
158+
const err = await pending;
159+
expect(err).toBeInstanceOf(Error);
160+
expect((err as Error).message).toBe("Login cancelled");
161+
});
162+
});

0 commit comments

Comments
 (0)