Skip to content

Commit 52305c8

Browse files
committed
feat(provider): Meta sign-in vs API-key chooser + auto-copy device url
1 parent cf6e5b1 commit 52305c8

11 files changed

Lines changed: 312 additions & 15 deletions

File tree

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,26 @@ describe("FIRST_CLASS_PROVIDERS", () => {
9595
}
9696
});
9797

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");
111+
expect(api?.baseURL).toBe("https://api.meta.ai/v1");
112+
expect(api?.defaultModel).toBe("muse-spark-1.3");
113+
expect((api?.models ?? []).length).toBeGreaterThan(0);
114+
expect(api?.models).toContain(api?.defaultModel);
115+
expect(api?.authHint).toContain("LLM|");
116+
});
117+
98118
test("API-key providers ship baseURL, models, and defaultModel", () => {
99119
for (const def of FIRST_CLASS_PROVIDERS) {
100120
if (def.auth !== "api-key") continue;

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ 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

1819
const OPENAI_API_MODELS = [
@@ -91,8 +92,26 @@ export const FIRST_CLASS_PROVIDERS: readonly FirstClassProviderDef[] = [
9192
{
9293
id: "meta",
9394
label: "Meta",
94-
auth: "oauth",
95-
oauth: "meta",
95+
auth: "chooser",
96+
paths: [
97+
{
98+
id: "signin",
99+
label: "Sign In",
100+
auth: "oauth",
101+
oauth: "meta",
102+
providerId: "meta",
103+
},
104+
{
105+
id: "api",
106+
label: "Meta API — API key",
107+
auth: "api-key",
108+
baseURL: META_BASE_URL,
109+
models: META_DEFAULT_MODELS,
110+
defaultModel: "muse-spark-1.3",
111+
authHint: "Paste your Meta API key (LLM|...)",
112+
providerId: "meta",
113+
},
114+
],
96115
},
97116
{
98117
id: OPENCODE_GO_PROVIDER_ID,

‎src/config/index.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,32 @@ export function buildMetaSource(fields: {
459459
};
460460
}
461461

462+
// Build the InferenceSource for a Meta Model API key (the Meta chooser's
463+
// "API key" path, a hand-named meta/<slug>, or a bare `meta` settings row).
464+
// The endpoint speaks the Responses protocol, so the source rides the same
465+
// "meta" adapter as the OAuth profiles — only the credential provenance
466+
// differs: a plain key is api-key provenance, never an OAuth profile.
467+
export function buildMetaApiKeySource(fields: {
468+
id: string;
469+
apiKey?: string;
470+
model: string;
471+
sessionId: string;
472+
reasoningEffort?: ReasoningEffort;
473+
}): InferenceSource {
474+
const providerOptions: Record<string, unknown> = {};
475+
if (fields.reasoningEffort !== undefined)
476+
providerOptions[META_REASONING_EFFORT_OPTION] = fields.reasoningEffort;
477+
registerSourceSecret(fields.id, fields.apiKey);
478+
return {
479+
id: fields.id,
480+
provider: META_PROVIDER,
481+
baseURL: META_BASE_URL,
482+
credentialId: fields.id,
483+
model: fields.model,
484+
defaults: { maxTokens: SOURCE_MAX_TOKENS, providerOptions },
485+
};
486+
}
487+
462488
// Build the InferenceSource for a Bifrost virtual-key provider. Routes to the
463489
// "bifrost" adapter (a thin openai-compatible wrapper) which injects the
464490
// x-bf-vk sentinel header.

‎src/config/inference-sources.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,57 @@ describe("source credential provenance", () => {
178178
});
179179
});
180180

181+
test("Meta API-key rows route to the meta-responses adapter, never chat completions", () => {
182+
const source = buildInferenceSourceForRef(
183+
{ provider: "meta", model: "muse-spark-1.3" },
184+
{
185+
sessionId: "sess-meta-key",
186+
catalog: [
187+
{
188+
name: "meta",
189+
baseURL: "https://api.meta.ai/v1",
190+
apiKey: "LLM|plain-key",
191+
models: ["muse-spark-1.3"],
192+
},
193+
],
194+
},
195+
undefined,
196+
);
197+
198+
if (source === null) throw new Error("expected Meta API-key source");
199+
expect(source.provider).toBe("meta");
200+
expect(source.baseURL).toBe("https://api.meta.ai/v1");
201+
expect(source.credentialId).toBe("meta");
202+
expect(readSourceCredentialRecord(source.credentialId).provenance).toEqual({
203+
kind: "api-key",
204+
});
205+
});
206+
207+
test("a namespaced Meta API-key row routes to the meta-responses adapter", () => {
208+
const source = buildInferenceSourceForRef(
209+
{ provider: "meta/personal", model: "muse-spark-1.2" },
210+
{
211+
sessionId: "sess-meta-slug",
212+
catalog: [
213+
{
214+
name: "meta/personal",
215+
baseURL: "https://api.meta.ai/v1",
216+
apiKey: "LLM|plain-key",
217+
models: ["muse-spark-1.2"],
218+
},
219+
],
220+
},
221+
undefined,
222+
);
223+
224+
if (source === null) throw new Error("expected Meta API-key source");
225+
expect(source.provider).toBe("meta");
226+
expect(source.baseURL).toBe("https://api.meta.ai/v1");
227+
expect(readSourceCredentialRecord(source.credentialId).provenance).toEqual({
228+
kind: "api-key",
229+
});
230+
});
231+
181232
test("namespaced API-key rows are not inferred as OAuth", () => {
182233
const source = buildInferenceSourceForRef(
183234
{ provider: "codex/shadow", model: "relay-model" },

‎src/config/inference-sources.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildBifrostSource,
55
buildCodexSource,
66
buildGoSource,
7+
buildMetaApiKeySource,
78
buildMetaSource,
89
buildZenSource,
910
buildAnthropicSource,
@@ -26,6 +27,7 @@ import {
2627
} from "../provider/reasoning-effort.js";
2728
import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js";
2829
import { isZenProvider } from "../../packages/zen/src/index.js";
30+
import { isMetaProviderName } from "./meta-providers.js";
2931
import { customReasoningSettings } from "./providers.js";
3032

3133
export interface BuildSourceContext {
@@ -146,6 +148,27 @@ export function buildInferenceSourceForRef(
146148
...(effort !== undefined ? { reasoningEffort: effort } : {}),
147149
});
148150
}
151+
// Meta Model API key rows (the Meta chooser's "API key" path, a hand-named
152+
// meta/<slug>, or a bare `meta` row) also speak the Responses protocol at
153+
// META_BASE_URL — never the openai-compatible /chat/completions fallback
154+
// below. They have no metaProfile marker, so route them to the same
155+
// meta-responses adapter as the OAuth profiles.
156+
if (
157+
ref.provider === "meta" ||
158+
(entry?.metaProfile === undefined && isMetaProviderName(ref.provider))
159+
) {
160+
return buildMetaApiKeySource({
161+
id: ref.provider,
162+
...(entry?.apiKey !== undefined
163+
? { apiKey: entry.apiKey }
164+
: providerSettings?.apiKey !== undefined
165+
? { apiKey: providerSettings.apiKey }
166+
: {}),
167+
model: ref.model,
168+
sessionId: ctx.sessionId,
169+
...(effort !== undefined ? { reasoningEffort: effort } : {}),
170+
});
171+
}
149172
if (
150173
isOpenCodeGoProvider({
151174
name: ref.provider,

‎src/tui/provider-setup.test.ts‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ function stagedLoginStarter(
9898
opts: {
9999
onStart?: (input: StagedLoginInput) => void;
100100
deniedStarts?: number;
101+
/** Emit a device_code event via `notify` on the first start (Meta flow). */
102+
emitDeviceCode?: boolean;
101103
} = {},
102104
): {
103105
start: OAuthLoginStarter;
@@ -124,6 +126,13 @@ function stagedLoginStarter(
124126
start: async (input) => {
125127
events.starts.push(input);
126128
opts.onStart?.(input);
129+
if (opts.emitDeviceCode === true && events.starts.length === 1) {
130+
input.notify?.({
131+
type: "device_code",
132+
verificationUri: "https://auth.meta.com/device?code=ABCD-1234",
133+
userCode: "ABCD-1234",
134+
});
135+
}
127136
input.signal.addEventListener("abort", () => {
128137
events.aborted = true;
129138
});
@@ -349,7 +358,9 @@ describe("provider setup pure helpers", () => {
349358
{ name: "codex/default" },
350359
]);
351360
expect(rows.map((r) => r.id)).toContain(CUSTOM_CHOICE_ID);
352-
expect(rows.map((r) => r.id)).toEqual(choices.map((c) => c.id));
361+
// Alt+A rows are keyed by the pick-list row id (two Meta rows stay
362+
// distinct), which mirrors the onboarding list.
363+
expect(rows.map((r) => r.id)).toEqual(choices.map((c) => c.rowId));
353364
const openai = rows.find((r) => r.id === "openai");
354365
const codex = rows.find((r) => r.id === "codex");
355366
const custom = rows.find((r) => r.id === CUSTOM_CHOICE_ID);
@@ -485,6 +496,21 @@ async function mountConfig(
485496
return { done, harness };
486497
}
487498

499+
/** Recording clipboard port for the Meta device-flow copy test. */
500+
function recordingClipboard(): {
501+
clipboard: { writeText: (t: string) => void; writes: string[] };
502+
} {
503+
const writes: string[] = [];
504+
return {
505+
clipboard: {
506+
writes,
507+
writeText: (text: string) => {
508+
writes.push(text);
509+
},
510+
},
511+
};
512+
}
513+
488514
async function mountSetup(
489515
onSubmit: ProviderSetupSubmit = async () => undefined,
490516
showTelemetryNotice = false,
@@ -597,6 +623,7 @@ async function mountLogin(opts: {
597623
onSubmit?: ProviderSetupSubmit;
598624
loginTimeoutMs?: number;
599625
listOAuthProfiles?: OAuthProfileLister;
626+
clipboard?: ProviderSetupConfig["clipboard"];
600627
}): Promise<{ done: Promise<boolean>; harness: Harness }> {
601628
return mountConfig({
602629
onSubmit: opts.onSubmit ?? (async () => undefined),
@@ -605,6 +632,7 @@ async function mountLogin(opts: {
605632
...(opts.loginTimeoutMs !== undefined
606633
? { loginTimeoutMs: opts.loginTimeoutMs }
607634
: {}),
635+
...(opts.clipboard !== undefined ? { clipboard: opts.clipboard } : {}),
608636
});
609637
}
610638

@@ -1189,6 +1217,60 @@ describe("runProviderSetup sign-in", () => {
11891217
harness.pressKey("Ctrl+C");
11901218
expect(await done).toBe(false);
11911219
});
1220+
1221+
test("the Meta device flow copies the verification URI to the clipboard", async () => {
1222+
const rec = recordingClipboard();
1223+
const { start } = stagedLoginStarter({ emitDeviceCode: true });
1224+
const { done, harness } = await mountLogin({
1225+
start,
1226+
clipboard: rec.clipboard,
1227+
});
1228+
await pickRow(harness, PROVIDER_IDS, "meta");
1229+
await nameOAuthAccount(harness);
1230+
const frame = harness.captureCharFrame();
1231+
expect(frame).toContain("https://auth.meta.com/device?code=ABCD-1234");
1232+
expect(frame).toContain("ABCD-1234");
1233+
expect(rec.clipboard.writes).toEqual([
1234+
"https://auth.meta.com/device?code=ABCD-1234",
1235+
]);
1236+
harness.pressKey("Ctrl+C");
1237+
expect(await done).toBe(false);
1238+
});
1239+
1240+
test("the Meta chooser exposes two arrow-navigable rows with distinct ids", () => {
1241+
const metaRows = providerChoiceRows(providerChoices()).filter((r) =>
1242+
r.label.includes("Meta"),
1243+
);
1244+
expect(metaRows.map((r) => r.id)).toEqual(["meta", "meta/api"]);
1245+
expect(providerChoiceById("meta")?.oauth).toBe("meta");
1246+
expect(providerChoiceById("meta/api")?.oauth).toBeNull();
1247+
expect(providerChoiceById("meta/api")?.baseURL).toBe(
1248+
"https://api.meta.ai/v1",
1249+
);
1250+
expect(providerChoiceById("meta/api")?.defaultModel).toBe("muse-spark-1.3");
1251+
});
1252+
1253+
test("picking the Meta API-key row walks the key path, not the sign-in", async () => {
1254+
const seen: ProviderFormValues[] = [];
1255+
const { done, harness } = await mountSetup(async (values) => {
1256+
seen.push({ ...values });
1257+
});
1258+
await pickRow(harness, PROVIDER_IDS, "meta/api");
1259+
await flush(harness);
1260+
expect(harness.captureCharFrame()).toContain("step 2 of 4");
1261+
// Instance name step, then key — not the login step.
1262+
harness.pressKey("Enter");
1263+
await harness.renderOnce();
1264+
type(harness, "LLM|test-key");
1265+
harness.pressKey("Enter");
1266+
await harness.renderOnce();
1267+
harness.pressKey("Enter");
1268+
await harness.renderOnce();
1269+
expect(await done).toBe(true);
1270+
expect(seen[0]?.name).toBe("meta/default");
1271+
expect(seen[0]?.apiKey).toBe("LLM|test-key");
1272+
expect(seen[0]?.model).toBe("muse-spark-1.3");
1273+
});
11921274
});
11931275

11941276
describe("runProviderSetup", () => {

0 commit comments

Comments
 (0)