mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
Resolve Paseo Agent credentials asynchronously
This commit is contained in:
@@ -535,7 +535,7 @@ export class PaseoAgentClient implements AgentClient {
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const inferenceProviders = paseoAgentInferenceProviders(this.config);
|
||||
const inferenceProviders = await paseoAgentInferenceProviders(this.config);
|
||||
if (inferenceProviders.length === 0) {
|
||||
throw new Error(
|
||||
"Paseo Agent has no configured inference providers. Add agents.paseo.providers to your Paseo config.",
|
||||
|
||||
@@ -115,8 +115,8 @@ describe("listPaseoAgentModels", () => {
|
||||
});
|
||||
|
||||
describe("paseoAgentInferenceProviders (per-type defaults)", () => {
|
||||
it("applies openrouter defaults (base url, api, model fields)", () => {
|
||||
const [provider] = paseoAgentInferenceProviders(configWith());
|
||||
it("applies openrouter defaults (base url, api, model fields)", async () => {
|
||||
const [provider] = await paseoAgentInferenceProviders(configWith());
|
||||
expect(provider.name).toBe("openrouter-main");
|
||||
expect(provider.config.baseUrl).toBe("https://openrouter.ai/api/v1");
|
||||
expect(provider.config.apiKey).toBe("sk-test");
|
||||
@@ -130,19 +130,19 @@ describe("paseoAgentInferenceProviders (per-type defaults)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the type's env var when no apiKey is given", () => {
|
||||
it("falls back to the type's env var when no apiKey is given", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
anthropic: { type: "anthropic", options: { models: [{ id: "claude-x" }] } },
|
||||
},
|
||||
});
|
||||
const [provider] = paseoAgentInferenceProviders(config);
|
||||
const [provider] = await paseoAgentInferenceProviders(config);
|
||||
expect(provider.config.baseUrl).toBe("https://api.anthropic.com");
|
||||
expect(provider.config.apiKey).toBe("$ANTHROPIC_API_KEY");
|
||||
expect(provider.config.models?.[0]?.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", () => {
|
||||
it("supports an OpenCode Zen / openai-compatible endpoint with per-model api override", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
zen: {
|
||||
@@ -155,13 +155,13 @@ describe("paseoAgentInferenceProviders (per-type defaults)", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const [provider] = paseoAgentInferenceProviders(config);
|
||||
const [provider] = await paseoAgentInferenceProviders(config);
|
||||
expect(provider.config.baseUrl).toBe("https://opencode.ai/zen/v1");
|
||||
expect(provider.config.models?.[0]?.api).toBe("openai-completions");
|
||||
expect(provider.config.models?.[1]?.api).toBe("anthropic-messages");
|
||||
});
|
||||
|
||||
it("passes through the custom escape hatch (explicit api + authHeader)", () => {
|
||||
it("passes through the custom escape hatch (explicit api + authHeader)", async () => {
|
||||
const config = PaseoAgentConfigSchema.parse({
|
||||
providers: {
|
||||
vertex: {
|
||||
@@ -177,7 +177,7 @@ describe("paseoAgentInferenceProviders (per-type defaults)", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const [provider] = paseoAgentInferenceProviders(config);
|
||||
const [provider] = await paseoAgentInferenceProviders(config);
|
||||
expect(provider.config.api).toBe("google-generative-ai");
|
||||
expect(provider.config.authHeader).toBe(true);
|
||||
expect(provider.config.headers).toEqual({ "x-extra": "1" });
|
||||
@@ -288,8 +288,8 @@ describe("openai-codex (ChatGPT subscription) provider", () => {
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("maps to a codex inference provider with an oauth marker and no api key", () => {
|
||||
const [provider] = paseoAgentInferenceProviders(codexConfig({}), {});
|
||||
it("maps to a codex inference provider with an oauth marker and no api key", async () => {
|
||||
const [provider] = await paseoAgentInferenceProviders(codexConfig({}), {});
|
||||
expect(provider.name).toBe("chatgpt");
|
||||
expect(provider.oauth).toEqual({ kind: "openai-codex" });
|
||||
expect(provider.config.apiKey).toBeUndefined();
|
||||
@@ -298,9 +298,11 @@ describe("openai-codex (ChatGPT subscription) provider", () => {
|
||||
expect(provider.config.models?.[0]?.api).toBe("openai-codex-responses");
|
||||
});
|
||||
|
||||
it("carries an advanced self-supplied refresh token resolved from an env var", () => {
|
||||
it("carries an advanced self-supplied refresh token resolved from an env var", async () => {
|
||||
const config = codexConfig({ refreshToken: "$CODEX_REFRESH_TOKEN" });
|
||||
const [provider] = paseoAgentInferenceProviders(config, { CODEX_REFRESH_TOKEN: "rt-env" });
|
||||
const [provider] = await paseoAgentInferenceProviders(config, {
|
||||
CODEX_REFRESH_TOKEN: "rt-env",
|
||||
});
|
||||
expect(provider.oauth).toEqual({ kind: "openai-codex", refreshToken: "rt-env" });
|
||||
});
|
||||
|
||||
|
||||
@@ -268,45 +268,46 @@ function toPiModels(entry: PaseoAgentInferenceProviderEntry, settings: ResolvedP
|
||||
* normally lives in the Paseo-owned store (populated by `paseo login chatgpt`); an
|
||||
* advanced `options.refreshToken` may supply the user's own token instead.
|
||||
*/
|
||||
export function paseoAgentInferenceProviders(
|
||||
export async function paseoAgentInferenceProviders(
|
||||
config: PaseoAgentConfig,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): PaseoAgentInferenceProvider[] {
|
||||
return entries(config).flatMap(([name, entry]): PaseoAgentInferenceProvider[] => {
|
||||
): Promise<PaseoAgentInferenceProvider[]> {
|
||||
const providers: PaseoAgentInferenceProvider[] = [];
|
||||
|
||||
for (const [name, entry] of entries(config)) {
|
||||
const settings = resolveProviderSettings(entry);
|
||||
const models = toPiModels(entry, settings);
|
||||
|
||||
if (entry.type === "openai-codex") {
|
||||
const refreshToken = entry.options.refreshToken
|
||||
? resolveRefreshTokenExpression(entry.options.refreshToken, env)
|
||||
? await resolveRefreshTokenExpression(entry.options.refreshToken, env)
|
||||
: undefined;
|
||||
return [
|
||||
{
|
||||
name,
|
||||
config: {
|
||||
...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}),
|
||||
...(settings.api ? { api: settings.api } : {}),
|
||||
models,
|
||||
},
|
||||
oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
providers.push({
|
||||
name,
|
||||
config: {
|
||||
...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}),
|
||||
...(settings.apiKey ? { apiKey: settings.apiKey } : {}),
|
||||
...(settings.api ? { api: settings.api } : {}),
|
||||
...(settings.headers ? { headers: settings.headers } : {}),
|
||||
...(settings.authHeader ? { authHeader: settings.authHeader } : {}),
|
||||
models,
|
||||
},
|
||||
oauth: { kind: "openai-codex" as const, ...(refreshToken ? { refreshToken } : {}) },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
providers.push({
|
||||
name,
|
||||
config: {
|
||||
...(settings.baseUrl ? { baseUrl: settings.baseUrl } : {}),
|
||||
...(settings.apiKey ? { apiKey: settings.apiKey } : {}),
|
||||
...(settings.api ? { api: settings.api } : {}),
|
||||
...(settings.headers ? { headers: settings.headers } : {}),
|
||||
...(settings.authHeader ? { authHeader: settings.authHeader } : {}),
|
||||
models,
|
||||
},
|
||||
];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
/** Enumerate configured models as Paseo model definitions (no Pi disk/auth reads). */
|
||||
@@ -363,7 +364,7 @@ export function paseoAgentHasUsableModel(
|
||||
export function resolvePaseoAgentModel(
|
||||
config: PaseoAgentConfig,
|
||||
requestedModelId: string | null | undefined,
|
||||
registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentInferenceProviders(config),
|
||||
registeredProviders: PaseoAgentInferenceProvider[] = paseoAgentModelInventory(config),
|
||||
agentDefaultModelId?: string | null,
|
||||
): PaseoAgentModelReference | undefined {
|
||||
if (requestedModelId) {
|
||||
@@ -383,6 +384,13 @@ export function resolvePaseoAgentModel(
|
||||
return firstRegisteredModel(registeredProviders);
|
||||
}
|
||||
|
||||
function paseoAgentModelInventory(config: PaseoAgentConfig): PaseoAgentInferenceProvider[] {
|
||||
return entries(config).map(([name, entry]) => {
|
||||
const settings = resolveProviderSettings(entry);
|
||||
return { name, config: { models: toPiModels(entry, settings) } };
|
||||
});
|
||||
}
|
||||
|
||||
function firstModelId(config: PaseoAgentConfig): string | undefined {
|
||||
for (const [name, entry] of entries(config)) {
|
||||
const first = entry.options.models[0];
|
||||
|
||||
@@ -6,19 +6,23 @@ import {
|
||||
} from "./oauth-credentials.js";
|
||||
|
||||
describe("resolveRefreshTokenExpression", () => {
|
||||
it("returns a literal value", () => {
|
||||
expect(resolveRefreshTokenExpression("rt-literal", {})).toBe("rt-literal");
|
||||
it("returns a literal value", async () => {
|
||||
await expect(resolveRefreshTokenExpression("rt-literal", {})).resolves.toBe("rt-literal");
|
||||
});
|
||||
|
||||
it("resolves an env reference and returns undefined when unset", () => {
|
||||
expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).toBe("rt-env");
|
||||
expect(resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" })).toBe("rt-env");
|
||||
expect(resolveRefreshTokenExpression("$CODEX_RT", {})).toBeUndefined();
|
||||
it("resolves an env reference and returns undefined when unset", async () => {
|
||||
await expect(resolveRefreshTokenExpression("$CODEX_RT", { CODEX_RT: "rt-env" })).resolves.toBe(
|
||||
"rt-env",
|
||||
);
|
||||
await expect(
|
||||
resolveRefreshTokenExpression("${CODEX_RT}", { CODEX_RT: "rt-env" }),
|
||||
).resolves.toBe("rt-env");
|
||||
await expect(resolveRefreshTokenExpression("$CODEX_RT", {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("runs a !command and returns its trimmed output", () => {
|
||||
expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).toBe("rt-cmd");
|
||||
expect(resolveRefreshTokenExpression("!exit 1", {})).toBeUndefined();
|
||||
it("runs a !command asynchronously and returns its trimmed output", async () => {
|
||||
await expect(resolveRefreshTokenExpression("!printf rt-cmd", {})).resolves.toBe("rt-cmd");
|
||||
await expect(resolveRefreshTokenExpression("!exit 1", {})).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { exec } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { hasEnvReference, substituteEnvReferences } from "./env-references.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const CREDENTIAL_COMMAND_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Resolution of a *self-supplied* OAuth refresh token expression — a literal, an env
|
||||
// reference (`$VAR` / `${VAR}`), or a `!command` that prints the token. This is an
|
||||
// advanced/manual escape hatch for users who already hold their own ChatGPT/Codex
|
||||
@@ -17,23 +21,40 @@ import { hasEnvReference, substituteEnvReferences } from "./env-references.js";
|
||||
export function resolveRefreshTokenExpression(
|
||||
value: string,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string | undefined {
|
||||
): Promise<string | undefined> {
|
||||
if (value.startsWith("!")) {
|
||||
const command = value.slice(1).trim();
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const output = execSync(command, { encoding: "utf8", env }).trim();
|
||||
return output.length > 0 ? output : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
return execAsync(command, {
|
||||
encoding: "utf8",
|
||||
env,
|
||||
timeout: CREDENTIAL_COMMAND_TIMEOUT_MS,
|
||||
})
|
||||
.then(({ stdout }) => {
|
||||
const output = stdout.trim();
|
||||
return output.length > 0 ? output : undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
return Promise.resolve(resolveStaticRefreshTokenExpression(value, env));
|
||||
}
|
||||
|
||||
function resolveStaticRefreshTokenExpression(
|
||||
value: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
): string | undefined {
|
||||
if (hasEnvReference(value)) {
|
||||
const substituted = substituteEnvReferences(value, env);
|
||||
return substituted && substituted.length > 0 ? substituted : undefined;
|
||||
const output = substituteEnvReferences(value, env);
|
||||
if (output) {
|
||||
return output.length > 0 ? output : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user