diff --git a/src/api-key.ts b/src/api-key.ts new file mode 100644 index 0000000..2195e51 --- /dev/null +++ b/src/api-key.ts @@ -0,0 +1,53 @@ +import { existsSync, readFileSync } from "node:fs" +import { homedir } from "node:os" +import { join } from "node:path" + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +function defaultAuthPaths(home: string): string[] { + return [join(home, ".commandcode", "auth.json"), join(home, ".pi", "agent", "auth.json")] +} + +export function getConfiguredApiKey( + options: { + env?: NodeJS.ProcessEnv + authPaths?: readonly string[] + homeDir?: () => string + } = {}, +): string | undefined { + const env = options.env ?? process.env + if (env.COMMANDCODE_API_KEY) return env.COMMANDCODE_API_KEY + + const home = options.homeDir?.() ?? homedir() + const authPaths = options.authPaths ?? defaultAuthPaths(home) + + for (const authPath of authPaths) { + try { + if (!existsSync(authPath)) continue + const parsed: unknown = JSON.parse(readFileSync(authPath, "utf-8")) + if (!isRecord(parsed)) continue + + const apiKey = stringValue(parsed.apiKey) + if (apiKey) return apiKey + + const commandcode = stringValue(parsed.commandcode) + if (commandcode) return commandcode + + const providerKey = isRecord(parsed.commandcode) ? parsed.commandcode : undefined + if (providerKey && stringValue(providerKey.type) === "oauth") { + const access = stringValue(providerKey.access) + if (access) return access + } + } catch { + // Ignore malformed or unreadable auth files. + } + } + + return undefined +} diff --git a/src/models.ts b/src/models.ts index c4be26f..daef654 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,4 +1,7 @@ -export const DEFAULT_MODELS_URL = "https://api.commandcode.ai/provider/v1/models" +import type { Api } from "@mariozechner/pi-ai" + +export const DEFAULT_PROVIDER_API_BASE = "https://api.commandcode.ai/provider/v1" +export const DEFAULT_MODELS_URL = `${DEFAULT_PROVIDER_API_BASE}/models` const DEFAULT_MAX_OUTPUT_TOKENS = 65_536 @@ -11,6 +14,7 @@ interface ApiModel { export interface CommandCodeModel { id: string name: string + api: Api reasoning: boolean contextWindow: number maxTokens: number @@ -47,6 +51,17 @@ function parseApiModel(value: unknown): ApiModel { } } +export function apiForModelId(id: string): Api { + if (id.startsWith("claude-")) return "anthropic-messages" + return "openai-completions" +} + +export function baseUrlForModel(apiBase: string, api: Api): string { + const normalized = apiBase.replace(/\/+$/g, "") + if (api !== "anthropic-messages") return normalized + return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized +} + export function commandCodeModelsFromApiResponse(value: unknown): readonly CommandCodeModel[] { if (!isRecord(value)) throw new Error("Expected models response to be an object") if (value.object !== "list") throw new Error("Expected models response object to be 'list'") @@ -57,6 +72,7 @@ export function commandCodeModelsFromApiResponse(value: unknown): readonly Comma return data.map(parseApiModel).map((model) => ({ id: model.id, name: `${model.name} (CC)`, + api: apiForModelId(model.id), reasoning: true, contextWindow: model.contextLength, maxTokens: Math.min(model.contextLength, DEFAULT_MAX_OUTPUT_TOKENS), diff --git a/src/oauth.ts b/src/oauth.ts index be77391..c775138 100644 --- a/src/oauth.ts +++ b/src/oauth.ts @@ -1,13 +1,13 @@ /** * Command Code OAuth provider for pi's /login flow. * - * Implements a browser-assisted API key retrieval flow: - * 1. Starts a local HTTP server on a Command Code CLI-compatible port - * 2. Opens the Command Code Studio auth page in the browser - * 3. The user authenticates on the Command Code website - * 4. The website POSTs the API key back to the local server - * 5. If browser transfer fails, the user can paste the API key manually - * 6. The API key is stored in pi's auth.json as OAuth credentials + * Implements two API key retrieval flows: + * 1. Browser-assisted login: opens Command Code Studio and waits for the + * website to POST the API key back to a local callback server. + * 2. Direct API key login: prompts the user to paste a Command Code Studio API key. + * + * If browser transfer fails, the user can still paste the API key manually. + * The API key is stored in pi's auth.json as OAuth credentials. * * Since Command Code API keys don't expire, we store them as * OAuth credentials with a far-future expiry. @@ -101,13 +101,35 @@ async function promptForApiKey(callbacks: OAuthLoginCallbacks, message: string) return credentialsFromApiKey(apiKey) } -/** - * Starts the browser-based login flow for Command Code. - * - * Returns OAuth credentials where access == refresh == the user's API key. - * The keys don't expire, so we set a far-future expiry. - */ -export async function login(callbacks: OAuthLoginCallbacks): Promise { +type LoginChoice = { type: "browser" } | { type: "prompt" } | { type: "apiKey"; apiKey: string } + +async function chooseLoginFlow(callbacks: OAuthLoginCallbacks): Promise { + const input = sanitizeApiKey( + await callbacks.onPrompt({ + message: + "Command Code login: press Enter for browser login, type 'key' to paste an API key, or paste the API key directly:", + }), + ) + const normalized = input.toLowerCase() + + if (!input || normalized === "1" || normalized === "b" || normalized === "browser") { + return { type: "browser" } + } + + if ( + normalized === "2" || + normalized === "k" || + normalized === "key" || + normalized === "api" || + normalized === "paste" + ) { + return { type: "prompt" } + } + + return { type: "apiKey", apiKey: input } +} + +async function browserLogin(callbacks: OAuthLoginCallbacks): Promise { let authServer try { authServer = await startAuthServer() @@ -151,6 +173,23 @@ export async function login(callbacks: OAuthLoginCallbacks): Promise { + const choice = await chooseLoginFlow(callbacks) + + if (choice.type === "apiKey") return credentialsFromApiKey(choice.apiKey) + if (choice.type === "prompt") { + return promptForApiKey(callbacks, "Paste your Command Code API key:") + } + + return browserLogin(callbacks) +} + /** * Command Code API keys don't expire, so "refresh" is a no-op. * Returns the same credentials with an updated far-future expiry. diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 00e2d04..0000000 --- a/src/types.ts +++ /dev/null @@ -1,156 +0,0 @@ -export type StopReason = "stop" | "length" | "toolUse" -export type ErrorReason = "error" | "aborted" -export type TerminalReason = StopReason | ErrorReason - -export interface UsageCost { - input: number - output: number - cacheRead: number - cacheWrite: number - total: number -} - -export interface Usage { - input: number - output: number - cacheRead: number - cacheWrite: number - totalTokens: number - cost: UsageCost -} - -export interface TextContent { - type: "text" - text: string -} - -export interface ThinkingContent { - type: "thinking" - thinking: string -} - -export interface ToolCallContent { - type: "toolCall" - id: string - name: string - arguments: Record -} - -export type AssistantContent = TextContent | ThinkingContent | ToolCallContent - -export interface AssistantMessageLike { - role: "assistant" - content: AssistantContent[] - api: unknown - provider: string - model: string - usage: Usage - stopReason: TerminalReason - errorMessage?: string - timestamp: number -} - -export interface ModelLike { - id: string - api: unknown - provider: string - maxTokens: number -} - -export interface MessageLike { - role: string - content?: unknown - toolCallId?: string - toolName?: string - isError?: boolean -} - -export interface ToolLike { - name: string - description?: string - parameters?: unknown -} - -export interface ContextLike { - systemPrompt?: string - messages?: readonly MessageLike[] - tools?: readonly ToolLike[] -} - -export interface ProviderResponseInfo { - status: number - headers: Record -} - -export interface StreamOptions { - apiKey?: string - signal?: AbortSignal - headers?: Record - maxTokens?: number - onPayload?: (payload: unknown, model: ModelLike) => unknown | Promise - onResponse?: (response: ProviderResponseInfo, model: ModelLike) => void | Promise -} - -export type AssistantMessageEvent = - | { type: "start"; partial: AssistantMessageLike } - | { type: "text_start"; contentIndex: number; partial: AssistantMessageLike } - | { - type: "text_delta" - contentIndex: number - delta: string - partial: AssistantMessageLike - } - | { - type: "text_end" - contentIndex: number - content: string - partial: AssistantMessageLike - } - | { - type: "thinking_start" - contentIndex: number - partial: AssistantMessageLike - } - | { - type: "thinking_delta" - contentIndex: number - delta: string - partial: AssistantMessageLike - } - | { - type: "thinking_end" - contentIndex: number - content: string - partial: AssistantMessageLike - } - | { - type: "toolcall_start" - contentIndex: number - partial: AssistantMessageLike - } - | { - type: "toolcall_end" - contentIndex: number - toolCall: ToolCallContent - partial: AssistantMessageLike - } - | { type: "done"; reason: StopReason; message: AssistantMessageLike } - | { type: "error"; reason: ErrorReason; error: AssistantMessageLike } - -export interface AssistantMessageEventStreamLike extends AsyncIterable { - push(event: AssistantMessageEvent): void - end(): void -} - -export interface CoreDependencies { - createStream: () => AssistantMessageEventStreamLike - calculateCost: (model: ModelLike, usage: Usage) => void - apiBase?: string - fetchImpl?: typeof fetch - authPaths?: readonly string[] - env?: NodeJS.ProcessEnv - cwd?: () => string - now?: () => number - uuid?: () => string - homeDir?: () => string -} diff --git a/tests/helpers.ts b/tests/helpers.ts deleted file mode 100644 index c05c7b3..0000000 --- a/tests/helpers.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { createServer, type IncomingHttpHeaders, type Server } from "node:http" - -import { - createStreamCommandCode, - type AssistantMessageEvent, - type AssistantMessageEventStreamLike, - type ContextLike, - type CoreDependencies, - type ModelLike, - type Usage, -} from "../src/core.ts" - -export function createTestEventStream(): AssistantMessageEventStreamLike { - const events: AssistantMessageEvent[] = [] - const waiters: Array<() => void> = [] - let ended = false - - const wake = () => { - const waiter = waiters.shift() - if (waiter) waiter() - } - - return { - push(event: AssistantMessageEvent) { - events.push(event) - wake() - }, - end() { - ended = true - while (waiters.length > 0) wake() - }, - [Symbol.asyncIterator]() { - let index = 0 - return { - async next(): Promise> { - while (index >= events.length && !ended) { - await new Promise((resolve) => waiters.push(resolve)) - } - if (index < events.length) { - const value = events[index] - index += 1 - return { done: false, value } - } - return { done: true, value: undefined } - }, - } - }, - } -} - -export async function collectEvents( - stream: AssistantMessageEventStreamLike, - timeoutMs = 2_000, -): Promise { - const events: AssistantMessageEvent[] = [] - - const collect = async () => { - for await (const event of stream) { - events.push(event) - if (event.type === "done" || event.type === "error") break - } - return events - } - - return await Promise.race([ - collect(), - new Promise((_, reject) => { - setTimeout( - () => reject(new Error(`Timed out collecting stream events after ${timeoutMs}ms`)), - timeoutMs, - ) - }), - ]) -} - -export function makeModel(overrides: Partial = {}): ModelLike { - return { - id: "deepseek/deepseek-v4-flash", - api: "commandcode-custom", - provider: "commandcode", - maxTokens: 384_000, - ...overrides, - } -} - -export function makeContext(overrides: Partial = {}): ContextLike { - return { - systemPrompt: "You are a test assistant.", - messages: [{ role: "user", content: "hello" }], - tools: [], - ...overrides, - } -} - -export interface TestDepsResult { - streamCommandCode: ReturnType - calculatedUsages: Usage[] -} - -export function createTestDeps(overrides: Partial = {}): TestDepsResult { - const calculatedUsages: Usage[] = [] - const streamCommandCode = createStreamCommandCode({ - createStream: createTestEventStream, - calculateCost: (_model, usage) => { - calculatedUsages.push({ - ...usage, - cost: { ...usage.cost }, - }) - }, - env: {}, - authPaths: [], - now: () => new Date("2026-05-05T12:00:00Z").getTime(), - uuid: () => "00000000-0000-4000-8000-000000000000", - cwd: () => "/repo", - ...overrides, - }) - return { streamCommandCode, calculatedUsages } -} - -type SuccessPlan = { - type: "success" - status?: number - events?: string[] - chunks?: string[] - delays?: number[] - hangAfterLast?: boolean -} - -type ErrorPlan = { - type: "error" - status: number - body: string -} - -export type ResponsePlan = SuccessPlan | ErrorPlan - -function headersToRecord(headers: IncomingHttpHeaders): Record { - const out: Record = {} - for (const [key, value] of Object.entries(headers)) { - if (typeof value === "string") out[key] = value - else if (Array.isArray(value)) out[key] = value.join(", ") - } - return out -} - -export interface MockCommandCodeServer { - baseUrl(): string - mockResponse(plan: ResponsePlan): void - reset(): void - close(): Promise - lastRequestBody(): unknown - lastRequestHeaders(): Record - requestCount(): number - responseClosedBeforeEnd(): boolean -} - -export async function startMockCommandCodeServer(): Promise { - let nextPlan: ResponsePlan = { type: "success", events: [] } - let lastBody: unknown - let lastHeaders: Record = {} - let requests = 0 - let closedBeforeEnd = false - let port = 0 - - const server: Server = createServer((req, res) => { - if (req.method !== "POST" || req.url !== "/alpha/generate") { - res.writeHead(404) - res.end("Not found") - return - } - - requests += 1 - lastHeaders = headersToRecord(req.headers) - let body = "" - req.on("data", (chunk: Buffer) => { - body += chunk.toString("utf-8") - }) - req.on("end", () => { - try { - const parsed: unknown = JSON.parse(body) - lastBody = parsed - } catch { - lastBody = undefined - } - - const plan = nextPlan - if (plan.type === "error") { - res.writeHead(plan.status, { "Content-Type": "text/plain" }) - res.end(plan.body) - return - } - - res.writeHead(plan.status ?? 200, { - "Content-Type": "text/plain; charset=utf-8", - "Transfer-Encoding": "chunked", - }) - - let ended = false - res.on("close", () => { - if (!ended) closedBeforeEnd = true - }) - - const chunks = plan.chunks ?? (plan.events ?? []).map((event) => `${event}\n`) - const delays = plan.delays ?? chunks.map(() => 0) - let index = 0 - - const sendNext = () => { - if (index >= chunks.length) { - if (!plan.hangAfterLast) { - ended = true - res.end() - } - return - } - - res.write(chunks[index]) - index += 1 - if (index < chunks.length) { - setTimeout(sendNext, delays[index] ?? 0) - } else if (!plan.hangAfterLast) { - ended = true - res.end() - } - } - - sendNext() - }) - }) - - await new Promise((resolve) => { - server.listen(0, () => { - const address = server.address() - if (typeof address === "object" && address) port = address.port - resolve() - }) - }) - - return { - baseUrl: () => `http://127.0.0.1:${port}`, - mockResponse(plan: ResponsePlan) { - nextPlan = plan - }, - reset() { - nextPlan = { type: "success", events: [] } - lastBody = undefined - lastHeaders = {} - requests = 0 - closedBeforeEnd = false - }, - close() { - return new Promise((resolve) => server.close(() => resolve())) - }, - lastRequestBody: () => lastBody, - lastRequestHeaders: () => lastHeaders, - requestCount: () => requests, - responseClosedBeforeEnd: () => closedBeforeEnd, - } -} - -export function objectAt(value: unknown, path: readonly string[]): unknown { - let current = value - for (const key of path) { - if (Array.isArray(current)) { - const index = Number(key) - if (!Number.isInteger(index)) return undefined - current = current[index] - continue - } - if (typeof current !== "object" || current === null) return undefined - current = Object.getOwnPropertyDescriptor(current, key)?.value - } - return current -} diff --git a/tests/test-abort.ts b/tests/test-abort.ts deleted file mode 100644 index 8db0ad5..0000000 --- a/tests/test-abort.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Abort tests against the real streamCommandCode core. - */ - -import assert from "node:assert/strict" -import { after, before, beforeEach, describe, it } from "node:test" - -import { - collectEvents, - createTestDeps, - makeContext, - makeModel, - startMockCommandCodeServer, - type MockCommandCodeServer, -} from "./helpers.ts" - -let server: MockCommandCodeServer - -before(async () => { - server = await startMockCommandCodeServer() -}) - -after(async () => { - await server.close() -}) - -beforeEach(() => { - server.reset() -}) - -describe("streamCommandCode — abort behavior", () => { - it("emits aborted error when signal is already aborted", async () => { - const controller = new AbortController() - controller.abort() - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const events = await collectEvents( - streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - signal: controller.signal, - }), - ) - - assert.deepEqual( - events.map((event) => event.type), - ["start", "error"], - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - assert.equal(error.error.stopReason, "aborted") - assert.equal(server.requestCount(), 0) - }) - - it("emits aborted error and cancels the response reader mid-stream", async () => { - server.mockResponse({ - type: "success", - events: [JSON.stringify({ type: "text-delta", text: "first" })], - hangAfterLast: true, - }) - const controller = new AbortController() - const { streamCommandCode } = createTestDeps({ apiBase: server.baseUrl() }) - - const stream = streamCommandCode(makeModel(), makeContext(), { - apiKey: "mock-key", - signal: controller.signal, - }) - - setTimeout(() => controller.abort(), 50) - const events = await collectEvents(stream, 2_000) - - assert.ok( - events.some((event) => event.type === "text_delta"), - "stream should process data before abort", - ) - const error = events.at(-1) - assert.equal(error?.type, "error") - if (error?.type !== "error") throw new Error("expected error") - assert.equal(error.reason, "aborted") - assert.equal(error.error.errorMessage, "Request aborted") - await new Promise((resolve) => setTimeout(resolve, 50)) - assert.ok(server.responseClosedBeforeEnd(), "abort should close the hanging upstream response") - }) -}) diff --git a/tests/test-api-key.ts b/tests/test-api-key.ts new file mode 100644 index 0000000..922c476 --- /dev/null +++ b/tests/test-api-key.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { describe, it } from "node:test" + +import { getConfiguredApiKey } from "../src/api-key.ts" + +describe("getConfiguredApiKey()", () => { + it("uses COMMANDCODE_API_KEY from provided env", () => { + assert.equal( + getConfiguredApiKey({ env: { COMMANDCODE_API_KEY: "env-key" }, authPaths: [] }), + "env-key", + ) + }) + + it("reads apiKey, commandcode, and pi OAuth credential fields from explicit auth paths", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-")) + try { + const first = join(dir, "first.json") + const second = join(dir, "second.json") + const oauth = join(dir, "oauth.json") + writeFileSync(first, JSON.stringify({ apiKey: "file-key" })) + writeFileSync(second, JSON.stringify({ commandcode: "fallback-key" })) + writeFileSync( + oauth, + JSON.stringify({ + commandcode: { + type: "oauth", + access: "oauth-access-key", + refresh: "oauth-refresh-key", + expires: Date.now() + 3600000, + }, + }), + ) + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [first, second] }), "file-key") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [second] }), "fallback-key") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [oauth] }), "oauth-access-key") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("ignores malformed auth files", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-auth-bad-")) + try { + const bad = join(dir, "bad.json") + writeFileSync(bad, "not json") + assert.equal(getConfiguredApiKey({ env: {}, authPaths: [bad] }), undefined) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it("uses injected homeDir for default auth paths", () => { + const dir = mkdtempSync(join(tmpdir(), "cc-home-")) + try { + const authDir = join(dir, ".pi", "agent") + mkdirSync(authDir, { recursive: true }) + writeFileSync(join(authDir, "auth.json"), JSON.stringify({ commandcode: "pi-key" })) + assert.equal(getConfiguredApiKey({ env: {}, homeDir: () => dir }), "pi-key") + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/tests/test-models.ts b/tests/test-models.ts index 15ee3fd..a2f75c0 100644 --- a/tests/test-models.ts +++ b/tests/test-models.ts @@ -1,7 +1,28 @@ import assert from "node:assert/strict" import { describe, it } from "node:test" -import { commandCodeModelsFromApiResponse } from "../src/models.ts" +import { apiForModelId, baseUrlForModel, commandCodeModelsFromApiResponse } from "../src/models.ts" + +describe("apiForModelId()", () => { + it("routes Claude models to Anthropic Messages and other models to OpenAI Chat Completions", () => { + assert.equal(apiForModelId("claude-sonnet-4-6"), "anthropic-messages") + assert.equal(apiForModelId("Qwen/Qwen3.7-Max"), "openai-completions") + assert.equal(apiForModelId("deepseek/deepseek-v4-flash"), "openai-completions") + }) +}) + +describe("baseUrlForModel()", () => { + it("uses the Provider API /v1 base for OpenAI-compatible models and strips /v1 for Anthropic SDK models", () => { + assert.equal( + baseUrlForModel("https://api.commandcode.ai/provider/v1", "openai-completions"), + "https://api.commandcode.ai/provider/v1", + ) + assert.equal( + baseUrlForModel("https://api.commandcode.ai/provider/v1", "anthropic-messages"), + "https://api.commandcode.ai/provider", + ) + }) +}) describe("commandCodeModelsFromApiResponse()", () => { it("converts the Provider API model list to pi models", () => { @@ -23,6 +44,7 @@ describe("commandCodeModelsFromApiResponse()", () => { { id: "Qwen/Qwen3.7-Max", name: "Qwen 3.7 Max (CC)", + api: "openai-completions", reasoning: true, contextWindow: 1_000_000, maxTokens: 65_536, diff --git a/tests/test-oauth.ts b/tests/test-oauth.ts index 0ff0fab..62c0123 100644 --- a/tests/test-oauth.ts +++ b/tests/test-oauth.ts @@ -185,8 +185,8 @@ describe("login()", () => { onAuth(params: { url: string }) { authUrl = params.url }, - onPrompt(_params: { message: string }): Promise { - throw new Error("onPrompt should not be called in browser flow") + async onPrompt(_params: { message: string }): Promise { + return "" }, } @@ -248,6 +248,7 @@ describe("login()", () => { }, async onPrompt(params: { message: string }): Promise { promptMessage = params.message + if (promptMessage.includes("press Enter for browser login")) return "" return "\u001b[200~ user_manualApiKey\n\u001b[201~" }, }) @@ -263,14 +264,48 @@ describe("login()", () => { } }) + it("accepts a pasted API key without starting browser login", async () => { + let authOpened = false + const result = await login({ + onAuth(_params: { url: string }) { + authOpened = true + }, + async onPrompt(_params: { message: string }): Promise { + return "\u001b[200~ user_directApiKey\n\u001b[201~" + }, + }) + + assert.equal(authOpened, false) + assert.equal(result.access, "user_directApiKey") + assert.equal(result.refresh, "user_directApiKey") + assert.ok(result.expires > Date.now(), "expiry should be far in the future") + }) + + it("can explicitly choose the API key prompt", async () => { + const prompts: string[] = [] + const result = await login({ + onAuth(_params: { url: string }) { + throw new Error("onAuth should not be called for direct API key flow") + }, + async onPrompt(params: { message: string }): Promise { + prompts.push(params.message) + return prompts.length === 1 ? "key" : "user_promptedApiKey" + }, + }) + + assert.equal(prompts.length, 2) + assert.equal(result.access, "user_promptedApiKey") + assert.equal(result.refresh, "user_promptedApiKey") + }) + it("rejects on state token mismatch", async () => { let authUrl = "" const callbacks = { onAuth(params: { url: string }) { authUrl = params.url }, - onPrompt(_params: { message: string }): Promise { - throw new Error("should not prompt") + async onPrompt(_params: { message: string }): Promise { + return "" }, }