@juvio15/pi-cliproxyapi-provider 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ /* oxlint-disable eslint/no-await-in-loop -- OAuth status polling is intentionally sequential so each check sees the latest server state. */
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+
4
+ import { Type } from "typebox";
5
+ import type { Static, TSchema } from "typebox";
6
+ import { Value } from "typebox/value";
7
+
8
+ import { resolveEndpoints } from "./connection.ts";
9
+
10
+ /** Shared options for the CLIProxyAPI v8 management OAuth flow. */
11
+ export interface OAuthRequestOptions {
12
+ baseUrl: string;
13
+ /** The CLIProxyAPI management key, not the client API key. */
14
+ managementKey: string;
15
+ fetch?: typeof globalThis.fetch;
16
+ signal?: AbortSignal;
17
+ /** Per-request timeout. */
18
+ timeoutMs?: number;
19
+ }
20
+
21
+ export interface StartOAuthOptions extends OAuthRequestOptions {
22
+ /**
23
+ * CLIProxyAPI provider id: `claude`, `codex`, `antigravity`, `kimi`,
24
+ * `kimi-ai`, `xai`, `devin`, `meta`, or a registered plugin provider id.
25
+ * Any other non-empty id is passed through for the server to judge.
26
+ */
27
+ provider: string;
28
+ }
29
+
30
+ /** A started login: open `url`, then poll or cancel with `state`. */
31
+ export interface OAuthStart {
32
+ state: string;
33
+ url: string;
34
+ /** `device` for device-code logins (Kimi, xAI, Meta): no callback URL comes back, so poll the status directly. */
35
+ flow?: string;
36
+ /** The code to enter at `url` during a device login, when the server sends one. */
37
+ userCode?: string;
38
+ }
39
+
40
+ export interface SubmitOAuthCallbackOptions extends OAuthRequestOptions {
41
+ provider?: string;
42
+ /** The complete callback URL the browser landed on, with its code and state. */
43
+ redirectUrl?: string;
44
+ state?: string;
45
+ code?: string;
46
+ error?: string;
47
+ }
48
+
49
+ export interface PollOAuthStatusOptions extends OAuthRequestOptions {
50
+ state: string;
51
+ /** Delay between status checks while the login is pending. */
52
+ pollIntervalMs?: number;
53
+ /** Longest total wait before the pending login counts as failed. */
54
+ maxWaitMs?: number;
55
+ }
56
+
57
+ export type OAuthStatusValue = "error" | "ok" | "wait";
58
+
59
+ export interface OAuthStatus {
60
+ status: OAuthStatusValue;
61
+ error?: string;
62
+ }
63
+
64
+ const DEFAULT_TIMEOUT_MS = 10_000;
65
+ const DEFAULT_POLL_INTERVAL_MS = 2000;
66
+ const DEFAULT_MAX_WAIT_MS = 5 * 60_000;
67
+
68
+ const StartSchema = Type.Object({
69
+ flow: Type.Optional(Type.String()),
70
+ state: Type.String(),
71
+ status: Type.Optional(Type.String()),
72
+ url: Type.String(),
73
+ user_code: Type.Optional(Type.String()),
74
+ });
75
+
76
+ const StatusSchema = Type.Object({
77
+ error: Type.Optional(Type.String()),
78
+ status: Type.Union([
79
+ Type.Literal("error"),
80
+ Type.Literal("ok"),
81
+ Type.Literal("wait"),
82
+ ]),
83
+ });
84
+
85
+ const ErrorSchema = Type.Object({
86
+ error: Type.Optional(Type.String()),
87
+ });
88
+
89
+ const parseJson = <Schema extends TSchema>(
90
+ schema: Schema,
91
+ text: string
92
+ ): Static<Schema> | undefined => {
93
+ try {
94
+ const value: unknown = JSON.parse(text);
95
+
96
+ return Value.Check(schema, value) ? value : undefined;
97
+ } catch {
98
+ return undefined;
99
+ }
100
+ };
101
+
102
+ const nonEmpty = (value: string | undefined, name: string): string => {
103
+ const trimmed = value?.trim() ?? "";
104
+
105
+ if (!trimmed) {
106
+ throw new Error(`${name} is empty`);
107
+ }
108
+
109
+ return trimmed;
110
+ };
111
+
112
+ const managementUrl = (baseUrl: string): string =>
113
+ `${resolveEndpoints(baseUrl).rootUrl}/v8/management`;
114
+
115
+ const request = async <Schema extends TSchema>(
116
+ options: OAuthRequestOptions,
117
+ route: string,
118
+ schema: Schema,
119
+ init: RequestInit = {}
120
+ ): Promise<Static<Schema>> => {
121
+ const fetchFn = options.fetch ?? globalThis.fetch;
122
+ const managementKey = nonEmpty(options.managementKey, "managementKey");
123
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
124
+
125
+ const response = await fetchFn(`${managementUrl(options.baseUrl)}${route}`, {
126
+ ...init,
127
+ headers: {
128
+ Authorization: `Bearer ${managementKey}`,
129
+ "Content-Type": "application/json",
130
+ },
131
+ signal: options.signal
132
+ ? AbortSignal.any([options.signal, timeout])
133
+ : timeout,
134
+ });
135
+
136
+ const [routePath] = route.split("?");
137
+ const text = await response.text();
138
+
139
+ if (!response.ok) {
140
+ const detail = parseJson(ErrorSchema, text)?.error;
141
+
142
+ throw new Error(
143
+ `CLIProxyAPI management ${routePath} returned HTTP ${response.status}${detail ? `: ${detail}` : ""}`
144
+ );
145
+ }
146
+
147
+ const value = parseJson(schema, text);
148
+
149
+ if (value === undefined) {
150
+ throw new Error(
151
+ `CLIProxyAPI management ${routePath} returned an unexpected shape`
152
+ );
153
+ }
154
+
155
+ return value;
156
+ };
157
+
158
+ interface OAuthCallbackBody {
159
+ provider?: string;
160
+ redirect_url?: string;
161
+ state?: string;
162
+ code?: string;
163
+ error?: string;
164
+ }
165
+
166
+ /**
167
+ * Starts an upstream account login. The server answers
168
+ * `{ status, url, state }`: `url` is the authorization URL to open and
169
+ * `state` identifies the pending session for the callback, status, and
170
+ * cancellation calls.
171
+ */
172
+ export const startOAuth = async (
173
+ options: StartOAuthOptions
174
+ ): Promise<OAuthStart> => {
175
+ const provider = nonEmpty(options.provider, "provider");
176
+
177
+ const result = await request(
178
+ options,
179
+ `/oauth/auth-url?provider=${encodeURIComponent(provider)}`,
180
+ StartSchema
181
+ );
182
+
183
+ const start: OAuthStart = { state: result.state, url: result.url };
184
+
185
+ if (result.flow) {
186
+ start.flow = result.flow;
187
+ }
188
+
189
+ if (result.user_code) {
190
+ start.userCode = result.user_code;
191
+ }
192
+
193
+ return start;
194
+ };
195
+
196
+ /**
197
+ * Submits the callback for a started login, either as the complete
198
+ * `redirect_url` the browser landed on or as a bare `state` plus `code`.
199
+ * Acceptance only hands the values to the pending session; poll the status
200
+ * to learn whether the credential exchange finished.
201
+ */
202
+ export const submitOAuthCallback = async (
203
+ options: SubmitOAuthCallbackOptions
204
+ ): Promise<OAuthStatus> => {
205
+ const body: OAuthCallbackBody = {};
206
+
207
+ if (options.provider?.trim()) {
208
+ body.provider = options.provider.trim();
209
+ }
210
+
211
+ if (options.redirectUrl?.trim()) {
212
+ body.redirect_url = options.redirectUrl.trim();
213
+ } else {
214
+ const state = nonEmpty(options.state, "state");
215
+ const code = options.code?.trim() ?? "";
216
+ const error = options.error?.trim() ?? "";
217
+
218
+ if (!code && !error) {
219
+ throw new Error(
220
+ "submitOAuthCallback requires a redirectUrl, or a state with a code or error"
221
+ );
222
+ }
223
+
224
+ body.state = state;
225
+
226
+ if (code) {
227
+ body.code = code;
228
+ }
229
+
230
+ if (error) {
231
+ body.error = error;
232
+ }
233
+ }
234
+
235
+ return await request(options, "/oauth/callback", StatusSchema, {
236
+ body: JSON.stringify(body),
237
+ method: "POST",
238
+ });
239
+ };
240
+
241
+ /**
242
+ * Polls a started login until the server reports `ok` or `error`; `wait`
243
+ * means the login is still pending. A wait past `maxWaitMs` comes back as
244
+ * an `error` status, and aborting `signal` rejects instead.
245
+ */
246
+ export const pollOAuthStatus = async (
247
+ options: PollOAuthStatusOptions
248
+ ): Promise<OAuthStatus> => {
249
+ const state = nonEmpty(options.state, "state");
250
+ const interval = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
251
+ const deadline = Date.now() + (options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS);
252
+
253
+ while (true) {
254
+ options.signal?.throwIfAborted();
255
+
256
+ const result = await request(
257
+ options,
258
+ `/oauth/status?state=${encodeURIComponent(state)}`,
259
+ StatusSchema
260
+ );
261
+
262
+ if (result.status !== "wait") {
263
+ return result;
264
+ }
265
+
266
+ const remaining = deadline - Date.now();
267
+
268
+ if (remaining <= 0) {
269
+ return {
270
+ error: "Timed out waiting for the CLIProxyAPI OAuth login to complete.",
271
+ status: "error",
272
+ };
273
+ }
274
+
275
+ await delay(Math.min(interval, remaining), undefined, {
276
+ signal: options.signal,
277
+ });
278
+ }
279
+ };
@@ -0,0 +1,89 @@
1
+ import type {
2
+ Model,
3
+ Api,
4
+ ProviderStreams,
5
+ StreamOptions,
6
+ } from "@earendil-works/pi-ai";
7
+
8
+ import type { ThoughtSummary } from "./catalog.ts";
9
+
10
+ export interface PayloadSettings {
11
+ apiKey: string;
12
+ /** Read at request time so /fast changes existing sessions immediately. */
13
+ fast?: () => boolean;
14
+ thoughtSummaries?: Readonly<Record<string, ThoughtSummary>>;
15
+ }
16
+
17
+ interface RequestPayload {
18
+ thinking?: { display?: string };
19
+ service_tier?: string;
20
+ }
21
+
22
+ const applyPayloadSettings = (
23
+ payload: RequestPayload,
24
+ model: Model<Api>,
25
+ options: StreamOptions | undefined,
26
+ settings: PayloadSettings
27
+ ): void => {
28
+ if (model.api === "anthropic-messages") {
29
+ const { thinking } = payload;
30
+
31
+ if (!thinking) {
32
+ return;
33
+ }
34
+
35
+ // The SDK inserts summarized itself, so presence in the payload is not caller intent.
36
+ const explicit =
37
+ options && "thinkingDisplay" in options
38
+ ? options.thinkingDisplay
39
+ : undefined;
40
+
41
+ const configured =
42
+ settings.thoughtSummaries?.[model.id] ??
43
+ ("thoughtSummary" in model ? model.thoughtSummary : "omitted");
44
+
45
+ if (explicit === "summarized" || explicit === "omitted") {
46
+ thinking.display = explicit;
47
+ } else if (configured === "summarized") {
48
+ thinking.display = configured;
49
+ } else {
50
+ delete thinking.display;
51
+ }
52
+ }
53
+
54
+ if (
55
+ model.api === "openai-responses" &&
56
+ (settings.fast?.() ?? false) &&
57
+ "supportsFast" in model &&
58
+ model.supportsFast
59
+ ) {
60
+ payload.service_tier = "priority";
61
+ }
62
+ };
63
+
64
+ /** Run provider edits first; the caller always sees and can replace the final payload. */
65
+ export const wrapCliproxyStreams = (
66
+ streams: ProviderStreams,
67
+ settings: PayloadSettings
68
+ ): ProviderStreams => {
69
+ const withPayload = <T extends StreamOptions>(
70
+ options: T
71
+ ): T & StreamOptions => ({
72
+ ...options,
73
+ apiKey: options?.apiKey ?? settings.apiKey,
74
+ onPayload: async (payload, model) => {
75
+ // SAFETY: These wrappers receive request objects from pi-ai's three SDK adapters, not external JSON.
76
+ applyPayloadSettings(payload as RequestPayload, model, options, settings);
77
+
78
+ return (await options?.onPayload?.(payload, model)) ?? payload;
79
+ },
80
+ });
81
+
82
+ return {
83
+ ...streams,
84
+ stream: (model, context, options) =>
85
+ streams.stream(model, context, withPayload(options ?? {})),
86
+ streamSimple: (model, context, options) =>
87
+ streams.streamSimple(model, context, withPayload(options ?? {})),
88
+ };
89
+ };