diff --git a/README.md b/README.md index 63ae3b9..a383409 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,22 @@ Or run in the background process: `beecoder start &` 4. Enter **Ollama** under **Provider Dropdown** and select desired models. -### Pollinations Token -Create a token: [enter.pollinations.ai](https://enter.pollinations.ai/). Then create a file named `.beecoder` in your home directory and paste your token into it. +### Pollinations Login +Authorize BeeCoder to use your Pollinations account: ```sh -cd $HOME -echo "TOKEN" > .beecoder +beecoder login +``` + +Open the displayed URL, confirm the code, and choose the access you want to +grant. BeeCoder stores the delegated token in `~/.beecoder` with permissions +limited to your user account. + +App developers can set a registered public client ID for attribution. It is +optional and is not a secret: + +```sh +BEECODER_CLIENT_ID=pk_your_app_key beecoder login ``` ### Development @@ -50,4 +60,4 @@ npm start npm run build npm link beecoder -v -``` \ No newline at end of file +``` diff --git a/index.ts b/index.ts index 3f2a9a6..071f462 100644 --- a/index.ts +++ b/index.ts @@ -1,6 +1,11 @@ import Program from "./cli/Program.ts"; import { listen } from "./server/Server.ts"; -import { accessToken, Logger } from "./utils.ts"; +import { + accessToken, + authorizeDevice, + getAccessToken, + Logger +} from "./utils.ts"; import pkg from "./package.json" with { type: "json" }; @@ -11,6 +16,7 @@ import "./routes/v1.ts"; const prg = new Program(); prg.add("-v", () => Logger.log(pkg.version)); prg.add("--version", () => Logger.log(pkg.version)); +prg.add("login", login); prg.add("start", start); prg.add("-h", help); prg.add("--help", help); @@ -27,6 +33,7 @@ Usage: beecoder [flags] Available Commands: + login Authorize BeeCoder to use your Pollinations account start Start server Flags: @@ -37,11 +44,23 @@ Flags: async function start() { try { await accessToken(); + if (!getAccessToken()) { + throw Error("Not authenticated. Run `beecoder login` first."); + } await listen(); - + Logger.log("Listening: http://localhost:11434"); } catch (err) { Logger.error((err as Error).toString()); process.exit(1); } } + +async function login() { + try { + await authorizeDevice(); + } catch (err) { + Logger.error((err as Error).toString()); + process.exit(1); + } +} diff --git a/routes/v1.ts b/routes/v1.ts index 1605695..d75dcce 100644 --- a/routes/v1.ts +++ b/routes/v1.ts @@ -14,7 +14,7 @@ router.post("/v1/chat/completions", async (req, res) => { headers["Authorization"] = `Bearer ${token}`; } - const aiReq = await fetch("https://text.pollinations.ai/openai", { + const aiReq = await fetch("https://gen.pollinations.ai/v1/chat/completions", { method: "POST", body: req.body, headers diff --git a/utils.ts b/utils.ts index 6f5c9e5..d54e62b 100644 --- a/utils.ts +++ b/utils.ts @@ -1,6 +1,24 @@ +import { access, chmod, readFile, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { readFile, access } from "node:fs/promises"; + +const ENTER_URL = "https://enter.pollinations.ai"; +const CONFIG_FILE = join(homedir(), ".beecoder"); + +interface IDeviceCode { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete?: string; + expires_in: number; + interval: number; +} + +interface IDeviceToken { + access_token?: string; + error?: string; + error_description?: string; +} interface IModel { model: string; @@ -54,15 +72,94 @@ const models: IModel[] = [ ]; async function accessToken() { - const cfgFile = join(homedir(), ".beecoder"); - try { - await access(cfgFile); + await access(CONFIG_FILE); } catch { return; } - token = await readFile(cfgFile, { encoding: "utf8" }); + token = (await readFile(CONFIG_FILE, { encoding: "utf8" })).trim(); +} + +async function authorizeDevice() { + const clientId = process.env.BEECODER_CLIENT_ID?.trim(); + const codeRes = await fetch(`${ENTER_URL}/api/device/code`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(clientId ? { client_id: clientId } : {}) + }); + + if (!codeRes.ok) { + throw Error( + `Could not start login: ${codeRes.status} ${await codeRes.text()}` + ); + } + + const device = (await codeRes.json()) as IDeviceCode; + if ( + !device.device_code || + !device.user_code || + !device.verification_uri || + !Number.isFinite(device.expires_in) || + !Number.isFinite(device.interval) + ) { + throw Error("Pollinations returned an invalid device code response."); + } + + const verificationUrl = + device.verification_uri_complete ?? device.verification_uri; + + Logger.log(`Open ${verificationUrl}`); + Logger.log(`Enter code: ${device.user_code}`); + Logger.log("Waiting for approval..."); + + const expiresAt = Date.now() + Math.max(device.expires_in, 1) * 1000; + let pollInterval = Math.max(device.interval, 1) * 1000; + + while (Date.now() < expiresAt) { + await sleep(Math.min(pollInterval, expiresAt - Date.now())); + if (Date.now() >= expiresAt) break; + + const tokenRes = await fetch(`${ENTER_URL}/api/device/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_code: device.device_code, + ...(clientId && { client_id: clientId }) + }) + }); + const result = (await tokenRes.json()) as IDeviceToken; + + if (tokenRes.ok && result.access_token) { + const accessToken = result.access_token.trim(); + await writeFile(CONFIG_FILE, accessToken, { + encoding: "utf8", + mode: 0o600 + }); + await chmod(CONFIG_FILE, 0o600); + token = accessToken; + Logger.log("Authenticated. Token stored in ~/.beecoder."); + return; + } + + if (result.error === "authorization_pending") continue; + if (result.error === "slow_down") { + pollInterval += 5000; + continue; + } + if (result.error === "access_denied") { + throw Error("Login denied."); + } + if (result.error === "expired_token") { + throw Error("Login expired. Run `beecoder login` again."); + } + + throw Error( + result.error_description ?? result.error ?? "Device login failed." + ); + } + + throw Error("Login expired. Run `beecoder login` again."); } function getAccessToken() { @@ -87,4 +184,8 @@ class Logger { } } -export { accessToken, getAccessToken, getModel, Logger }; +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export { accessToken, authorizeDevice, getAccessToken, getModel, Logger };