mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Add remote Paseo Agent configuration
This commit is contained in:
@@ -4,9 +4,9 @@ import { createCli } from "../../cli.js";
|
||||
import { createLoginCommand } from "./index.js";
|
||||
|
||||
interface RecordedLogin {
|
||||
providerInstance: string;
|
||||
envHome: string | undefined;
|
||||
mode: "browser" | "device";
|
||||
providerInstance?: string;
|
||||
envHome?: string | undefined;
|
||||
}
|
||||
|
||||
describe("paseo login command", () => {
|
||||
@@ -24,13 +24,15 @@ describe("paseo login command", () => {
|
||||
const flags = chatgpt?.options.map((option) => option.long);
|
||||
// Default flow is browser; device-code is an opt-in fallback.
|
||||
expect(flags).toContain("--device-code");
|
||||
expect(flags).toContain("--host");
|
||||
expect(flags).toContain("--home");
|
||||
// It must not require a copy/paste device flow by default.
|
||||
expect(chatgpt?.description().toLowerCase()).toContain("chatgpt");
|
||||
});
|
||||
|
||||
it("runs browser login by default and opens the Pi auth URL", async () => {
|
||||
it("runs browser login by default and stores the credential through the daemon", async () => {
|
||||
const recorded: RecordedLogin[] = [];
|
||||
const stored: unknown[] = [];
|
||||
const openedUrls: string[] = [];
|
||||
const output: string[] = [];
|
||||
|
||||
@@ -44,29 +46,57 @@ describe("paseo login command", () => {
|
||||
promptForCode: async () => {
|
||||
throw new Error("manual code prompt should not be used for successful browser login");
|
||||
},
|
||||
loginBrowser: async (options) => {
|
||||
recorded.push({
|
||||
providerInstance: options.providerInstance,
|
||||
envHome: options.env?.PASEO_HOME,
|
||||
mode: "browser",
|
||||
});
|
||||
loginBrowserCredential: async (options) => {
|
||||
recorded.push({ mode: "browser" });
|
||||
options.onAuthUrl("https://auth.openai.com/oauth/authorize?client_id=paseo");
|
||||
options.onProgress?.("callback complete");
|
||||
return { path: "/tmp/paseo-home/paseo-agent/auth.json" };
|
||||
return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 };
|
||||
},
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return {
|
||||
getLastServerInfoMessage: () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: { paseoAgentConfig: true },
|
||||
}),
|
||||
storePaseoAgentChatGptCredential: async (input) => {
|
||||
stored.push(input);
|
||||
return {
|
||||
requestId: "request-1",
|
||||
success: true,
|
||||
providerName: input.providerName,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
loginDeviceCode: async () => {
|
||||
throw new Error("device-code login should not be used by default");
|
||||
},
|
||||
});
|
||||
|
||||
await login.parseAsync(["node", "login", "chatgpt", "--home", "/tmp/paseo-home"]);
|
||||
await login.parseAsync(["node", "login", "chatgpt", "--host", "localhost:7777"]);
|
||||
|
||||
expect(recorded).toEqual([
|
||||
{ providerInstance: "chatgpt", envHome: "/tmp/paseo-home", mode: "browser" },
|
||||
expect(recorded).toEqual([{ mode: "browser" }]);
|
||||
expect(stored).toEqual([
|
||||
{
|
||||
providerName: "chatgpt",
|
||||
credential: {
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(openedUrls).toEqual(["https://auth.openai.com/oauth/authorize?client_id=paseo"]);
|
||||
expect(output.join("\n")).toContain("browser flow");
|
||||
expect(output.join("\n")).toContain("/tmp/paseo-home/paseo-agent/auth.json");
|
||||
expect(output.join("\n")).toContain("selected daemon (localhost:7777)");
|
||||
expect(output.join("\n")).not.toContain("access-token");
|
||||
expect(output.join("\n")).not.toContain("refresh-token");
|
||||
});
|
||||
|
||||
it("uses device-code login only when explicitly requested", async () => {
|
||||
@@ -82,9 +112,12 @@ describe("paseo login command", () => {
|
||||
promptForCode: async () => {
|
||||
throw new Error("manual browser prompt should not run for --device-code");
|
||||
},
|
||||
loginBrowser: async () => {
|
||||
loginBrowserCredential: async () => {
|
||||
throw new Error("browser login should not run for --device-code");
|
||||
},
|
||||
connectDaemon: async () => {
|
||||
throw new Error("daemon client should not be used for local --device-code");
|
||||
},
|
||||
loginDeviceCode: async (options) => {
|
||||
recorded.push({
|
||||
providerInstance: options.providerInstance,
|
||||
@@ -116,4 +149,116 @@ describe("paseo login command", () => {
|
||||
expect(output.join("\n")).toContain("headless device-code flow");
|
||||
expect(output.join("\n")).toContain("ABCD-EFGH");
|
||||
});
|
||||
|
||||
it("rejects --device-code with --host instead of writing local auth for a remote host", async () => {
|
||||
const output: string[] = [];
|
||||
const login = createLoginCommand({
|
||||
write: (message) => output.push(message),
|
||||
writeError: (message) => output.push(message),
|
||||
openBrowser: () => {
|
||||
throw new Error("browser opener should not run");
|
||||
},
|
||||
promptForCode: async () => {
|
||||
throw new Error("prompt should not run");
|
||||
},
|
||||
loginBrowserCredential: async () => {
|
||||
throw new Error("browser login should not run");
|
||||
},
|
||||
loginDeviceCode: async () => {
|
||||
throw new Error("device-code login should not run with --host");
|
||||
},
|
||||
connectDaemon: async () => {
|
||||
throw new Error("daemon client should not be used");
|
||||
},
|
||||
});
|
||||
|
||||
await login.parseAsync(["node", "login", "chatgpt", "--device-code", "--host", "remote:7777"]);
|
||||
|
||||
expect(output.join("\n")).toContain("--device-code cannot be combined with --host");
|
||||
});
|
||||
|
||||
it("asks for a host update instead of sending credentials to an old daemon", async () => {
|
||||
const stored: unknown[] = [];
|
||||
const output: string[] = [];
|
||||
const login = createLoginCommand({
|
||||
write: (message) => output.push(message),
|
||||
writeError: (message) => output.push(message),
|
||||
openBrowser: () => {
|
||||
throw new Error("browser opener should not run without the capability flag");
|
||||
},
|
||||
promptForCode: async () => {
|
||||
throw new Error("manual code prompt should not be used");
|
||||
},
|
||||
loginBrowserCredential: async () => {
|
||||
throw new Error("browser login should not run without the capability flag");
|
||||
},
|
||||
connectDaemon: async () => ({
|
||||
getLastServerInfoMessage: () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: {},
|
||||
}),
|
||||
storePaseoAgentChatGptCredential: async (input) => {
|
||||
stored.push(input);
|
||||
throw new Error("store RPC should not be called without the capability flag");
|
||||
},
|
||||
close: async () => {},
|
||||
}),
|
||||
loginDeviceCode: async () => {
|
||||
throw new Error("device-code login should not run");
|
||||
},
|
||||
});
|
||||
|
||||
await login.parseAsync(["node", "login", "chatgpt", "--host", "remote:7777"]);
|
||||
|
||||
expect(stored).toEqual([]);
|
||||
expect(output.join("\n")).toContain("Update the host to configure Paseo Agent providers.");
|
||||
});
|
||||
|
||||
it("does not echo password-bearing host URIs after remote login", async () => {
|
||||
const output: string[] = [];
|
||||
const login = createLoginCommand({
|
||||
write: (message) => output.push(message),
|
||||
writeError: (message) => output.push(message),
|
||||
openBrowser: () => true,
|
||||
promptForCode: async () => {
|
||||
throw new Error("manual code prompt should not be used");
|
||||
},
|
||||
loginBrowserCredential: async () => ({
|
||||
type: "oauth",
|
||||
access: "access-token",
|
||||
refresh: "refresh-token",
|
||||
expires: 123,
|
||||
}),
|
||||
connectDaemon: async () => ({
|
||||
getLastServerInfoMessage: () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: { paseoAgentConfig: true },
|
||||
}),
|
||||
storePaseoAgentChatGptCredential: async (input) => ({
|
||||
requestId: "request-1",
|
||||
success: true,
|
||||
providerName: input.providerName,
|
||||
auth: { kind: "oauth", configured: true, source: "stored" },
|
||||
error: null,
|
||||
}),
|
||||
close: async () => {},
|
||||
}),
|
||||
loginDeviceCode: async () => {
|
||||
throw new Error("device-code login should not run");
|
||||
},
|
||||
});
|
||||
|
||||
await login.parseAsync([
|
||||
"node",
|
||||
"login",
|
||||
"chatgpt",
|
||||
"--host",
|
||||
"tcp://remote:7777?ssl=true&password=super-secret",
|
||||
]);
|
||||
|
||||
expect(output.join("\n")).toContain("tcp://remote:7777?ssl=true");
|
||||
expect(output.join("\n")).not.toContain("super-secret");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
loginCodexBrowser,
|
||||
loginAndStoreCodex,
|
||||
loginAndStoreCodexBrowser,
|
||||
type CodexDeviceCodeInfo,
|
||||
type StoredCodexOAuthCredential,
|
||||
} from "@getpaseo/server";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
|
||||
import { addDaemonHostOption } from "../../utils/command-options.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { openBrowserUrl } from "../../utils/open-browser.js";
|
||||
|
||||
// First-class auth UX: `paseo login chatgpt`.
|
||||
// Default flow is browser OAuth (PKCE + local callback on 127.0.0.1:1455) via Pi's
|
||||
// helper; `--device-code` is a headless fallback. Credentials are stored in the
|
||||
// Paseo-owned store ($PASEO_HOME/paseo-agent/auth.json). No foreign auth files are read.
|
||||
// helper; credentials are then sent to the selected daemon for storage. `--device-code`
|
||||
// remains a local-only fallback until a daemon-run device-code RPC exists.
|
||||
|
||||
const PROVIDER_INSTANCE = "chatgpt";
|
||||
|
||||
interface LoginChatgptOptions {
|
||||
deviceCode?: boolean;
|
||||
home?: string;
|
||||
host?: string;
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
@@ -26,7 +31,12 @@ interface LoginResult {
|
||||
|
||||
interface LoginCommandDependencies {
|
||||
loginDeviceCode: typeof loginAndStoreCodex;
|
||||
loginBrowser: typeof loginAndStoreCodexBrowser;
|
||||
loginBrowserCredential: typeof loginCodexBrowser;
|
||||
connectDaemon: (options: {
|
||||
host?: string;
|
||||
}) => Promise<
|
||||
Pick<DaemonClient, "getLastServerInfoMessage" | "storePaseoAgentChatGptCredential" | "close">
|
||||
>;
|
||||
openBrowser: (url: string) => boolean;
|
||||
promptForCode: (message: string) => Promise<string>;
|
||||
write: (message: string) => void;
|
||||
@@ -35,7 +45,8 @@ interface LoginCommandDependencies {
|
||||
|
||||
const defaultDependencies: LoginCommandDependencies = {
|
||||
loginDeviceCode: loginAndStoreCodex,
|
||||
loginBrowser: loginAndStoreCodexBrowser,
|
||||
loginBrowserCredential: loginCodexBrowser,
|
||||
connectDaemon: connectToDaemon,
|
||||
openBrowser: openBrowserUrl,
|
||||
promptForCode,
|
||||
write: (message) => console.log(message),
|
||||
@@ -46,6 +57,29 @@ function resolveEnv(home: string | undefined): NodeJS.ProcessEnv {
|
||||
return home ? { ...process.env, PASEO_HOME: home } : process.env;
|
||||
}
|
||||
|
||||
function requirePaseoAgentConfigFeature(client: Pick<DaemonClient, "getLastServerInfoMessage">) {
|
||||
if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Update the host to configure Paseo Agent providers.");
|
||||
}
|
||||
|
||||
function formatDaemonTarget(host: string | undefined): string {
|
||||
if (!host) {
|
||||
return "local daemon";
|
||||
}
|
||||
try {
|
||||
if (host.startsWith("tcp://")) {
|
||||
const url = new URL(host);
|
||||
url.searchParams.delete("password");
|
||||
return `selected daemon (${url.toString()})`;
|
||||
}
|
||||
} catch {
|
||||
// Invalid hosts fail during connection; this path only formats the success message.
|
||||
}
|
||||
return `selected daemon (${host})`;
|
||||
}
|
||||
|
||||
async function promptForCode(message: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
try {
|
||||
@@ -69,6 +103,12 @@ async function runChatgptLogin(
|
||||
const env = resolveEnv(options.home);
|
||||
const { write } = dependencies;
|
||||
|
||||
if (options.deviceCode && options.host) {
|
||||
throw new Error(
|
||||
"--device-code cannot be combined with --host yet. Use browser login for remote hosts.",
|
||||
);
|
||||
}
|
||||
|
||||
if (options.deviceCode) {
|
||||
write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n");
|
||||
const { path } = await dependencies.loginDeviceCode({
|
||||
@@ -80,43 +120,59 @@ async function runChatgptLogin(
|
||||
return { path };
|
||||
}
|
||||
|
||||
write("Paseo login — ChatGPT/Codex subscription (browser flow)\n");
|
||||
const { path } = await dependencies.loginBrowser({
|
||||
providerInstance: PROVIDER_INSTANCE,
|
||||
env,
|
||||
onAuthUrl: (url) => {
|
||||
const opened = dependencies.openBrowser(url);
|
||||
write(
|
||||
opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:",
|
||||
);
|
||||
write(` ${url}\n`);
|
||||
write("Waiting for you to approve in the browser…");
|
||||
write("(If the browser didn't open, copy the URL above. You can also paste the code here.)");
|
||||
},
|
||||
onProgress: (message) => write(message),
|
||||
promptForCode: dependencies.promptForCode,
|
||||
});
|
||||
write(`\n✓ Logged in. Credential stored at ${path} (Paseo-owned, mode 0600).`);
|
||||
return { path };
|
||||
const client = await dependencies.connectDaemon({ host: options.host });
|
||||
try {
|
||||
requirePaseoAgentConfigFeature(client);
|
||||
write("Paseo login — ChatGPT/Codex subscription (browser flow)\n");
|
||||
const credential: StoredCodexOAuthCredential = await dependencies.loginBrowserCredential({
|
||||
onAuthUrl: (url) => {
|
||||
const opened = dependencies.openBrowser(url);
|
||||
write(
|
||||
opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:",
|
||||
);
|
||||
write(` ${url}\n`);
|
||||
write("Waiting for you to approve in the browser…");
|
||||
write(
|
||||
"(If the browser didn't open, copy the URL above. You can also paste the code here.)",
|
||||
);
|
||||
},
|
||||
onProgress: (message) => write(message),
|
||||
promptForCode: dependencies.promptForCode,
|
||||
});
|
||||
const result = await client.storePaseoAgentChatGptCredential({
|
||||
providerName: PROVIDER_INSTANCE,
|
||||
credential,
|
||||
});
|
||||
if (!result.success || result.error) {
|
||||
throw new Error(result.error ?? "Daemon rejected the ChatGPT credential");
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
|
||||
const target = formatDaemonTarget(options.host);
|
||||
write(`\n✓ Logged in. Credential stored on ${target} in its Paseo-owned auth store.`);
|
||||
return { path: target };
|
||||
}
|
||||
|
||||
export function createLoginCommand(dependencies: Partial<LoginCommandDependencies> = {}): Command {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const login = new Command("login").description("Authenticate Paseo providers");
|
||||
|
||||
login
|
||||
.command("chatgpt")
|
||||
.description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider")
|
||||
.option("--device-code", "Use the headless device-code flow instead of the browser flow")
|
||||
.option("--home <path>", "Paseo home directory (default: ~/.paseo or $PASEO_HOME)")
|
||||
.action(async (options: LoginChatgptOptions) => {
|
||||
try {
|
||||
await runChatgptLogin(options, deps);
|
||||
} catch (error) {
|
||||
deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
addDaemonHostOption(
|
||||
login
|
||||
.command("chatgpt")
|
||||
.description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider")
|
||||
.option("--device-code", "Use the headless device-code flow instead of the browser flow")
|
||||
.option("--home <path>", "Paseo home directory for local --device-code only"),
|
||||
).action(async (options: LoginChatgptOptions) => {
|
||||
try {
|
||||
await runChatgptLogin(options, deps);
|
||||
} catch (error) {
|
||||
deps.writeError(`Login failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
|
||||
return login;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Command } from "commander";
|
||||
import { runLsCommand } from "./ls.js";
|
||||
import { runModelsCommand } from "./models.js";
|
||||
import { addOpenRouterOptions, runAddOpenRouterCommand } from "./openrouter.js";
|
||||
import { withOutput } from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
|
||||
export function createProviderCommand(): Command {
|
||||
export function createProviderCommand(
|
||||
dependencies: Parameters<typeof runAddOpenRouterCommand>[3] = {},
|
||||
): Command {
|
||||
const provider = new Command("provider").description("Manage agent providers");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
@@ -19,5 +22,12 @@ export function createProviderCommand(): Command {
|
||||
.option("--thinking", "Include thinking option IDs for each model"),
|
||||
).action(withOutput(runModelsCommand));
|
||||
|
||||
const add = provider.command("add").description("Configure a provider");
|
||||
addJsonAndDaemonHostOptions(addOpenRouterOptions(add.command("openrouter"))).action(
|
||||
withOutput<Awaited<ReturnType<typeof runAddOpenRouterCommand>>["data"], [string]>(
|
||||
(name, options, command) => runAddOpenRouterCommand(name, options, command, dependencies),
|
||||
),
|
||||
);
|
||||
|
||||
return provider;
|
||||
}
|
||||
|
||||
119
packages/cli/src/commands/provider/openrouter.test.ts
Normal file
119
packages/cli/src/commands/provider/openrouter.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { render } from "../../output/index.js";
|
||||
import { runAddOpenRouterCommand } from "./openrouter.js";
|
||||
|
||||
describe("provider add openrouter", () => {
|
||||
it("sends OpenRouter config to the selected daemon and redacts output", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const result = await runAddOpenRouterCommand(
|
||||
"openrouter-main",
|
||||
{
|
||||
host: "localhost:7777",
|
||||
apiKeyStdin: true,
|
||||
model: ["anthropic/claude-3.7-sonnet", "openai/gpt-4o"],
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
readStdin: async () => "redaction-sentinel\n",
|
||||
env: {},
|
||||
connectDaemon: async (options) => {
|
||||
expect(options.host).toBe("localhost:7777");
|
||||
return {
|
||||
getLastServerInfoMessage: () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: { paseoAgentConfig: true },
|
||||
}),
|
||||
setPaseoAgentProvider: async (input) => {
|
||||
calls.push(input);
|
||||
return {
|
||||
requestId: "request-1",
|
||||
success: true,
|
||||
provider: {
|
||||
name: input.name,
|
||||
providerType: "openrouter",
|
||||
models: input.options.models,
|
||||
auth: { kind: "api_key", configured: true, source: "literal" },
|
||||
available: true,
|
||||
error: null,
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
close: async () => {},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
name: "openrouter-main",
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey: "redaction-sentinel",
|
||||
models: [{ id: "anthropic/claude-3.7-sonnet" }, { id: "openai/gpt-4o" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const json = render(result, { format: "json" });
|
||||
const table = render(result, { format: "table", noColor: true });
|
||||
expect(json).not.toContain("redaction-sentinel");
|
||||
expect(table).not.toContain("redaction-sentinel");
|
||||
expect(table).toContain("openrouter-main");
|
||||
expect(table).toContain("anthropic/claude-3.7-sonnet");
|
||||
});
|
||||
|
||||
it("uses OPENROUTER_API_KEY by default and requires explicit models", async () => {
|
||||
await expect(
|
||||
runAddOpenRouterCommand("openrouter-main", { model: [] }, {} as never, {
|
||||
env: { OPENROUTER_API_KEY: "redaction-sentinel" },
|
||||
readStdin: async () => {
|
||||
throw new Error("stdin should not be read");
|
||||
},
|
||||
connectDaemon: async () => {
|
||||
throw new Error("daemon should not be called without models");
|
||||
},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "MISSING_MODELS" });
|
||||
});
|
||||
|
||||
it("asks for a host update instead of sending provider config to an old daemon", async () => {
|
||||
const calls: unknown[] = [];
|
||||
|
||||
await expect(
|
||||
runAddOpenRouterCommand(
|
||||
"openrouter-main",
|
||||
{
|
||||
host: "localhost:7777",
|
||||
apiKeyStdin: true,
|
||||
model: ["anthropic/claude-3.7-sonnet"],
|
||||
},
|
||||
{} as never,
|
||||
{
|
||||
readStdin: async () => "redaction-sentinel\n",
|
||||
env: {},
|
||||
connectDaemon: async () => ({
|
||||
getLastServerInfoMessage: () => ({
|
||||
status: "server_info",
|
||||
serverId: "test-daemon",
|
||||
features: {},
|
||||
}),
|
||||
setPaseoAgentProvider: async (input) => {
|
||||
calls.push(input);
|
||||
throw new Error("set provider RPC should not run without the capability flag");
|
||||
},
|
||||
close: async () => {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the host to configure Paseo Agent providers.",
|
||||
});
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
182
packages/cli/src/commands/provider/openrouter.ts
Normal file
182
packages/cli/src/commands/provider/openrouter.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages";
|
||||
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js";
|
||||
|
||||
interface OpenRouterAddOptions extends CommandOptions {
|
||||
apiKey?: string;
|
||||
apiKeyEnv?: string;
|
||||
apiKeyStdin?: boolean;
|
||||
model?: string[];
|
||||
}
|
||||
|
||||
interface OpenRouterConfiguredItem {
|
||||
name: string;
|
||||
providerType: string;
|
||||
auth: string;
|
||||
available: string;
|
||||
models: string;
|
||||
}
|
||||
|
||||
interface OpenRouterDependencies {
|
||||
connectDaemon: (options: {
|
||||
host?: string;
|
||||
}) => Promise<Pick<DaemonClient, "getLastServerInfoMessage" | "setPaseoAgentProvider" | "close">>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
readStdin: () => Promise<string>;
|
||||
}
|
||||
|
||||
const DEFAULT_API_KEY_ENV = "OPENROUTER_API_KEY";
|
||||
|
||||
const defaultDependencies: OpenRouterDependencies = {
|
||||
connectDaemon: connectToDaemon,
|
||||
env: process.env,
|
||||
readStdin,
|
||||
};
|
||||
|
||||
export const openRouterConfiguredSchema: OutputSchema<OpenRouterConfiguredItem> = {
|
||||
idField: "name",
|
||||
columns: [
|
||||
{ header: "NAME", field: "name", width: 20 },
|
||||
{ header: "TYPE", field: "providerType", width: 12 },
|
||||
{ header: "AUTH", field: "auth", width: 16 },
|
||||
{ header: "AVAILABLE", field: "available", width: 10 },
|
||||
{ header: "MODELS", field: "models", width: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
async function readStdin(): Promise<string> {
|
||||
process.stdin.setEncoding("utf8");
|
||||
let value = "";
|
||||
for await (const chunk of process.stdin) {
|
||||
value += chunk;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeModels(rawModels: string[] | undefined): string[] {
|
||||
return (rawModels ?? [])
|
||||
.flatMap((value) => value.split(","))
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function resolveApiKey(
|
||||
options: OpenRouterAddOptions,
|
||||
dependencies: OpenRouterDependencies,
|
||||
): Promise<string> {
|
||||
if (options.apiKey) {
|
||||
return options.apiKey;
|
||||
}
|
||||
|
||||
if (options.apiKeyStdin) {
|
||||
const value = (await dependencies.readStdin()).trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
throw {
|
||||
code: "MISSING_API_KEY",
|
||||
message: "No OpenRouter API key was read from stdin",
|
||||
};
|
||||
}
|
||||
|
||||
const envName = options.apiKeyEnv ?? DEFAULT_API_KEY_ENV;
|
||||
const value = dependencies.env[envName]?.trim();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
throw {
|
||||
code: "MISSING_API_KEY",
|
||||
message: `OpenRouter API key not found in $${envName}`,
|
||||
details:
|
||||
"Set OPENROUTER_API_KEY, pass --api-key-env <name>, or pipe the key with --api-key-stdin.",
|
||||
};
|
||||
}
|
||||
|
||||
function toConfiguredItem(provider: RedactedPaseoAgentProviderConfig): OpenRouterConfiguredItem {
|
||||
return {
|
||||
name: provider.name,
|
||||
providerType: provider.providerType,
|
||||
auth: provider.auth.configured ? (provider.auth.source ?? "configured") : "not configured",
|
||||
available: provider.available ? "yes" : "no",
|
||||
models: provider.models.map((model) => model.id).join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
function requirePaseoAgentConfigFeature(client: Pick<DaemonClient, "getLastServerInfoMessage">) {
|
||||
if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) {
|
||||
return;
|
||||
}
|
||||
throw {
|
||||
code: "HOST_UPDATE_REQUIRED",
|
||||
message: "Update the host to configure Paseo Agent providers.",
|
||||
};
|
||||
}
|
||||
|
||||
export async function runAddOpenRouterCommand(
|
||||
name: string,
|
||||
options: OpenRouterAddOptions,
|
||||
_command: Command,
|
||||
dependencies: Partial<OpenRouterDependencies> = {},
|
||||
): Promise<SingleResult<OpenRouterConfiguredItem>> {
|
||||
const deps = { ...defaultDependencies, ...dependencies };
|
||||
const models = normalizeModels(options.model);
|
||||
if (models.length === 0) {
|
||||
throw {
|
||||
code: "MISSING_MODELS",
|
||||
message: "At least one OpenRouter model is required",
|
||||
details: "Pass --model <provider/model-id>. Repeat --model to configure more than one.",
|
||||
};
|
||||
}
|
||||
|
||||
const apiKey = await resolveApiKey(options, deps);
|
||||
const client = await deps.connectDaemon({ host: options.host });
|
||||
try {
|
||||
requirePaseoAgentConfigFeature(client);
|
||||
const result = await client.setPaseoAgentProvider({
|
||||
name,
|
||||
providerType: "openrouter",
|
||||
options: {
|
||||
apiKey,
|
||||
models: models.map((id) => ({ id })),
|
||||
},
|
||||
});
|
||||
if (!result.success || !result.provider) {
|
||||
throw {
|
||||
code: "PROVIDER_CONFIG_FAILED",
|
||||
message: result.error ?? "Daemon rejected the OpenRouter provider config",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "single",
|
||||
data: toConfiguredItem(result.provider),
|
||||
schema: openRouterConfiguredSchema,
|
||||
};
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
export function addOpenRouterOptions(command: Command): Command {
|
||||
return command
|
||||
.description("Configure an OpenRouter inference provider for Paseo Agent")
|
||||
.argument("<name>", "Provider instance name")
|
||||
.option(
|
||||
"--model <id>",
|
||||
"OpenRouter model ID to expose (repeatable, comma-separated also accepted)",
|
||||
collectMultiple,
|
||||
[],
|
||||
)
|
||||
.option(
|
||||
"--api-key-env <name>",
|
||||
`Environment variable containing the API key`,
|
||||
DEFAULT_API_KEY_ENV,
|
||||
)
|
||||
.option("--api-key-stdin", "Read the API key from stdin")
|
||||
.option("--api-key <key>", "OpenRouter API key (prefer env or stdin to avoid shell history)");
|
||||
}
|
||||
Reference in New Issue
Block a user