@f5-sales-demo/pi-ai 21.9.3 → 21.10.0

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/pi-ai",
4
- "version": "21.9.3",
4
+ "version": "21.10.0",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -46,7 +46,7 @@
46
46
  "@anthropic-ai/sdk": "^0.115",
47
47
  "@aws-sdk/client-bedrock-runtime": "^3",
48
48
  "@bufbuild/protobuf": "^2.11",
49
- "@f5-sales-demo/pi-utils": "21.9.3",
49
+ "@f5-sales-demo/pi-utils": "21.10.0",
50
50
  "@google/genai": "^2.15",
51
51
  "@sinclair/typebox": "^0.34",
52
52
  "@smithy/node-http-handler": "^4.4",
@@ -755,7 +755,7 @@ export class AuthStorage {
755
755
  provider: OAuthProviderId,
756
756
  ctrl: OAuthController & {
757
757
  /** onAuth is required by auth-storage but optional in OAuthController */
758
- onAuth: (info: { url: string; instructions?: string }) => void;
758
+ onAuth: (info: { url: string; openUrl?: string; instructions?: string }) => void;
759
759
  /** onPrompt is required for some providers (github-copilot, openai-codex) */
760
760
  onPrompt: (prompt: OAuthPrompt) => Promise<string>;
761
761
  },
package/src/cli.ts CHANGED
@@ -90,6 +90,7 @@ async function login(provider: OAuthProvider): Promise<void> {
90
90
  onProgress(message) {
91
91
  console.log(message);
92
92
  },
93
+ onManualCodeInput: () => promptFn("Paste the complete code#state from the hosted callback:"),
93
94
  });
94
95
  break;
95
96
 
@@ -1584,6 +1584,7 @@ export function anthropicModelManagerOptions(
1584
1584
  ): ModelManagerOptions<"anthropic-messages"> {
1585
1585
  const apiKey = config?.apiKey;
1586
1586
  const baseUrl = config?.baseUrl ?? ANTHROPIC_BASE_URL;
1587
+ const discoveryBaseUrl = /\/v1\/?$/.test(baseUrl) ? baseUrl.replace(/\/$/, "") : `${baseUrl.replace(/\/$/, "")}/v1`;
1587
1588
  return {
1588
1589
  providerId: "anthropic",
1589
1590
  modelsDev: {
@@ -1600,7 +1601,7 @@ export function anthropicModelManagerOptions(
1600
1601
  fetchOpenAICompatibleModels({
1601
1602
  api: "anthropic-messages",
1602
1603
  provider: "anthropic",
1603
- baseUrl,
1604
+ baseUrl: discoveryBaseUrl,
1604
1605
  headers: buildAnthropicDiscoveryHeaders(apiKey),
1605
1606
  mapModel: (
1606
1607
  entry: OpenAICompatibleModelRecord,
@@ -1613,6 +1614,7 @@ export function anthropicModelManagerOptions(
1613
1614
  return {
1614
1615
  ...defaults,
1615
1616
  name: discoveredName,
1617
+ baseUrl,
1616
1618
  };
1617
1619
  }
1618
1620
  return {
@@ -1,134 +1,259 @@
1
- /**
2
- * Anthropic OAuth flow (Claude Pro/Max)
3
- */
4
- import { OAuthCallbackFlow } from "./callback-server";
1
+ /** Anthropic OAuth flow for Claude subscriptions (Claude Pro/Max). */
5
2
  import { generatePKCE } from "./pkce";
6
- import type { OAuthController, OAuthCredentials } from "./types";
3
+ import type { OAuthAuthInfo, OAuthController, OAuthCredentials } from "./types";
7
4
 
8
- const decode = (s: string) => atob(s);
5
+ const decode = (value: string) => atob(value);
9
6
  const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
10
- const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
11
- const TOKEN_URL = "https://api.anthropic.com/v1/oauth/token";
12
- const CALLBACK_PORT = 54545;
7
+ const AUTHORIZE_URL = "https://claude.com/cai/oauth/authorize";
8
+ const TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
9
+ const MANUAL_REDIRECT_URL = "https://platform.claude.com/oauth/code/callback";
13
10
  const CALLBACK_PATH = "/callback";
14
- const SCOPES = "org:create_api_key user:profile user:inference";
11
+ const SCOPES = "user:profile user:inference";
12
+ const LOGIN_TIMEOUT_MS = 300_000;
13
+ const TOKEN_TIMEOUT_MS = 30_000;
15
14
 
16
- export class AnthropicOAuthFlow extends OAuthCallbackFlow {
17
- #verifier: string = "";
18
- #challenge: string = "";
15
+ export interface AnthropicOAuthFlowOptions {
16
+ timeoutMs?: number;
17
+ }
19
18
 
20
- constructor(ctrl: OAuthController) {
21
- super(ctrl, CALLBACK_PORT, CALLBACK_PATH);
22
- }
19
+ interface AnthropicTokenPayload {
20
+ access_token?: unknown;
21
+ refresh_token?: unknown;
22
+ expires_in?: unknown;
23
+ account_uuid?: unknown;
24
+ email_address?: unknown;
25
+ account?: unknown;
26
+ }
23
27
 
24
- async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
25
- const pkce = await generatePKCE();
26
- this.#verifier = pkce.verifier;
27
- this.#challenge = pkce.challenge;
28
+ function generateState(): string {
29
+ const bytes = new Uint8Array(32);
30
+ crypto.getRandomValues(bytes);
31
+ return Array.from(bytes, value => value.toString(16).padStart(2, "0")).join("");
32
+ }
28
33
 
29
- const authParams = new URLSearchParams({
30
- code: "true",
31
- client_id: CLIENT_ID,
32
- response_type: "code",
33
- redirect_uri: redirectUri,
34
- scope: SCOPES,
35
- code_challenge: this.#challenge,
36
- code_challenge_method: "S256",
37
- state,
38
- });
34
+ function authorizationUrl(redirectUri: string, state: string, challenge: string): string {
35
+ const params = new URLSearchParams({
36
+ code: "true",
37
+ client_id: CLIENT_ID,
38
+ response_type: "code",
39
+ redirect_uri: redirectUri,
40
+ scope: SCOPES,
41
+ code_challenge: challenge,
42
+ code_challenge_method: "S256",
43
+ state,
44
+ });
45
+ return `${AUTHORIZE_URL}?${params.toString()}`;
46
+ }
39
47
 
40
- const url = `${AUTHORIZE_URL}?${authParams.toString()}`;
41
- return { url };
48
+ function parseManualCode(input: string, expectedState: string): string {
49
+ const value = input.trim();
50
+ let code: string | undefined;
51
+ let state: string | undefined;
52
+ try {
53
+ const url = new URL(value);
54
+ code = url.searchParams.get("code") ?? undefined;
55
+ state = url.searchParams.get("state") ?? undefined;
56
+ } catch {
57
+ const separator = value.lastIndexOf("#");
58
+ if (separator > 0) {
59
+ code = value.slice(0, separator);
60
+ state = value.slice(separator + 1);
61
+ }
42
62
  }
63
+ if (!code || !state) throw new Error("Manual authorization requires code#state");
64
+ if (state !== expectedState) throw new Error("OAuth state mismatch");
65
+ return code;
66
+ }
43
67
 
44
- async exchangeToken(code: string, state: string, redirectUri: string): Promise<OAuthCredentials> {
45
- let exchangeCode = code;
46
- let exchangeState = state;
47
- const codeFragmentIndex = code.indexOf("#");
48
- if (codeFragmentIndex >= 0) {
49
- exchangeCode = code.slice(0, codeFragmentIndex);
50
- const codeFragmentState = code.slice(codeFragmentIndex + 1);
51
- if (codeFragmentState.length > 0) {
52
- exchangeState = codeFragmentState;
53
- }
54
- }
68
+ function tokenCredentials(payload: AnthropicTokenPayload, previousRefresh?: string): OAuthCredentials {
69
+ if (typeof payload.access_token !== "string" || typeof payload.expires_in !== "number") {
70
+ throw new Error("Anthropic token response was missing required fields");
71
+ }
72
+ const refresh =
73
+ typeof payload.refresh_token === "string" && payload.refresh_token ? payload.refresh_token : previousRefresh;
74
+ if (!refresh) throw new Error("Anthropic token response was missing a refresh token");
75
+ const account =
76
+ payload.account && typeof payload.account === "object" && !Array.isArray(payload.account)
77
+ ? (payload.account as Record<string, unknown>)
78
+ : undefined;
79
+ const accountId =
80
+ typeof account?.uuid === "string"
81
+ ? account.uuid
82
+ : typeof account?.id === "string"
83
+ ? account.id
84
+ : typeof payload.account_uuid === "string"
85
+ ? payload.account_uuid
86
+ : undefined;
87
+ const email =
88
+ typeof account?.email_address === "string"
89
+ ? account.email_address
90
+ : typeof account?.email === "string"
91
+ ? account.email
92
+ : typeof payload.email_address === "string"
93
+ ? payload.email_address
94
+ : undefined;
95
+ return {
96
+ access: payload.access_token,
97
+ refresh,
98
+ expires: Date.now() + payload.expires_in * 1000 - 5 * 60 * 1000,
99
+ ...(accountId && { accountId }),
100
+ ...(email && { email }),
101
+ };
102
+ }
55
103
 
56
- const tokenResponse = await fetch(TOKEN_URL, {
104
+ async function requestToken(body: Record<string, string>, previousRefresh?: string): Promise<OAuthCredentials> {
105
+ let response: Response;
106
+ try {
107
+ response = await fetch(TOKEN_URL, {
57
108
  method: "POST",
58
- headers: {
59
- "Content-Type": "application/json",
60
- Accept: "application/json",
61
- },
62
- body: JSON.stringify({
63
- grant_type: "authorization_code",
64
- client_id: CLIENT_ID,
65
- code: exchangeCode,
66
- state: exchangeState,
67
- redirect_uri: redirectUri,
68
- code_verifier: this.#verifier,
69
- }),
109
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
110
+ body: JSON.stringify(body),
111
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_MS),
70
112
  });
71
-
72
- if (!tokenResponse.ok) {
73
- let error: string;
74
- try {
75
- error = await tokenResponse.text();
76
- } catch {
77
- error = `HTTP ${tokenResponse.status}`;
78
- }
79
- throw new Error(`Token exchange failed: ${error}`);
113
+ } catch (error) {
114
+ if (error instanceof DOMException && error.name === "TimeoutError") {
115
+ throw new Error("Anthropic token exchange timed out after 30 seconds");
80
116
  }
117
+ throw new Error("Anthropic token exchange failed");
118
+ }
119
+ if (!response.ok) throw new Error(`Anthropic token exchange failed (HTTP ${response.status})`);
120
+ let payload: AnthropicTokenPayload;
121
+ try {
122
+ payload = (await response.json()) as AnthropicTokenPayload;
123
+ } catch {
124
+ throw new Error("Anthropic token response was invalid");
125
+ }
126
+ return tokenCredentials(payload, previousRefresh);
127
+ }
81
128
 
82
- const tokenData = (await tokenResponse.json()) as {
83
- access_token: string;
84
- refresh_token: string;
85
- expires_in: number;
86
- };
129
+ export class AnthropicOAuthFlow {
130
+ #verifier = "";
131
+ #challenge = "";
132
+ readonly #timeoutMs: number;
133
+
134
+ constructor(
135
+ readonly ctrl: OAuthController,
136
+ options: AnthropicOAuthFlowOptions = {},
137
+ ) {
138
+ this.#timeoutMs = options.timeoutMs ?? LOGIN_TIMEOUT_MS;
139
+ }
87
140
 
141
+ async generateAuthInfo(state: string, loopbackRedirectUri: string): Promise<OAuthAuthInfo & { openUrl: string }> {
142
+ if (!this.#verifier) {
143
+ const pkce = await generatePKCE();
144
+ this.#verifier = pkce.verifier;
145
+ this.#challenge = pkce.challenge;
146
+ }
88
147
  return {
89
- refresh: tokenData.refresh_token,
90
- access: tokenData.access_token,
91
- expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
148
+ url: authorizationUrl(MANUAL_REDIRECT_URL, state, this.#challenge),
149
+ openUrl: authorizationUrl(loopbackRedirectUri, state, this.#challenge),
150
+ instructions: "For a remote or headless terminal, open the displayed URL and paste the resulting code#state.",
92
151
  };
93
152
  }
153
+
154
+ /** Backward-compatible helper for callers that only need one redirect URL. */
155
+ async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
156
+ if (!this.#verifier) {
157
+ const pkce = await generatePKCE();
158
+ this.#verifier = pkce.verifier;
159
+ this.#challenge = pkce.challenge;
160
+ }
161
+ return { url: authorizationUrl(redirectUri, state, this.#challenge) };
162
+ }
163
+
164
+ async exchangeToken(codeInput: string, expectedState: string, redirectUri: string): Promise<OAuthCredentials> {
165
+ if (!this.#verifier) throw new Error("Anthropic OAuth exchange started without PKCE");
166
+ let code = codeInput;
167
+ const fragment = codeInput.lastIndexOf("#");
168
+ if (fragment >= 0) {
169
+ code = codeInput.slice(0, fragment);
170
+ const suppliedState = codeInput.slice(fragment + 1);
171
+ if (!suppliedState || suppliedState !== expectedState) throw new Error("OAuth state mismatch");
172
+ }
173
+ if (!code) throw new Error("Missing authorization code");
174
+ return requestToken({
175
+ grant_type: "authorization_code",
176
+ client_id: CLIENT_ID,
177
+ code,
178
+ state: expectedState,
179
+ redirect_uri: redirectUri,
180
+ code_verifier: this.#verifier,
181
+ });
182
+ }
183
+
184
+ async login(): Promise<OAuthCredentials> {
185
+ if (this.ctrl.signal?.aborted) throw new Error("Anthropic login cancelled");
186
+ const state = generateState();
187
+ const completed = Promise.withResolvers<{ code: string; redirectUri: string }>();
188
+ const server = Bun.serve({
189
+ hostname: "127.0.0.1",
190
+ port: 0,
191
+ fetch: request => {
192
+ const url = new URL(request.url);
193
+ if (url.pathname !== CALLBACK_PATH) return new Response("Not Found", { status: 404 });
194
+ const code = url.searchParams.get("code");
195
+ const callbackState = url.searchParams.get("state");
196
+ if (!code || callbackState !== state) {
197
+ completed.reject(
198
+ new Error(callbackState ? "OAuth state mismatch" : "Missing authorization code or state"),
199
+ );
200
+ return new Response("Authorization failed. Return to the terminal.", { status: 400 });
201
+ }
202
+ completed.resolve({ code, redirectUri: loopbackRedirectUri });
203
+ return new Response("Authorization complete. Return to the terminal.");
204
+ },
205
+ });
206
+ // Claude's registered native-client redirect uses localhost, while the
207
+ // listener remains explicitly bound to 127.0.0.1.
208
+ const loopbackRedirectUri = `http://localhost:${server.port}${CALLBACK_PATH}`;
209
+ try {
210
+ const authInfo = await this.generateAuthInfo(state, loopbackRedirectUri);
211
+ this.ctrl.onAuth?.(authInfo);
212
+ this.ctrl.onProgress?.("Waiting for Claude subscription authorization…");
213
+ const attempts: Promise<{ code: string; redirectUri: string }>[] = [completed.promise];
214
+ if (this.ctrl.onManualCodeInput) {
215
+ attempts.push(
216
+ this.ctrl.onManualCodeInput().then(input => ({
217
+ code: parseManualCode(input, state),
218
+ redirectUri: MANUAL_REDIRECT_URL,
219
+ })),
220
+ );
221
+ }
222
+ const timeoutSignal = AbortSignal.timeout(this.#timeoutMs);
223
+ const signal = this.ctrl.signal ? AbortSignal.any([this.ctrl.signal, timeoutSignal]) : timeoutSignal;
224
+ const aborted = new Promise<never>((_resolve, reject) => {
225
+ signal.addEventListener(
226
+ "abort",
227
+ () =>
228
+ reject(
229
+ new Error(this.ctrl.signal?.aborted ? "Anthropic login cancelled" : "Anthropic login timed out"),
230
+ ),
231
+ { once: true },
232
+ );
233
+ });
234
+ const result = await Promise.race([...attempts, aborted]);
235
+ this.ctrl.onProgress?.("Exchanging authorization code for tokens…");
236
+ return await this.exchangeToken(result.code, state, result.redirectUri);
237
+ } finally {
238
+ server.stop();
239
+ }
240
+ }
94
241
  }
95
242
 
96
- /**
97
- * Login with Anthropic OAuth
98
- */
99
- export async function loginAnthropic(ctrl: OAuthController): Promise<OAuthCredentials> {
100
- const flow = new AnthropicOAuthFlow(ctrl);
101
- return flow.login();
243
+ export async function loginAnthropic(
244
+ ctrl: OAuthController,
245
+ options: AnthropicOAuthFlowOptions = {},
246
+ ): Promise<OAuthCredentials> {
247
+ return new AnthropicOAuthFlow(ctrl, options).login();
102
248
  }
103
249
 
104
- /**
105
- * Refresh Anthropic OAuth token
106
- */
107
250
  export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
108
- const response = await fetch(TOKEN_URL, {
109
- method: "POST",
110
- headers: { "Content-Type": "application/json", Accept: "application/json" },
111
- body: JSON.stringify({
251
+ return requestToken(
252
+ {
112
253
  grant_type: "refresh_token",
113
254
  client_id: CLIENT_ID,
114
255
  refresh_token: refreshToken,
115
- }),
116
- });
117
-
118
- if (!response.ok) {
119
- const error = await response.text();
120
- throw new Error(`Anthropic token refresh failed: ${error}`);
121
- }
122
-
123
- const data = (await response.json()) as {
124
- access_token: string;
125
- refresh_token: string;
126
- expires_in: number;
127
- };
128
-
129
- return {
130
- refresh: data.refresh_token || refreshToken,
131
- access: data.access_token,
132
- expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
133
- };
256
+ },
257
+ refreshToken,
258
+ );
134
259
  }
@@ -65,7 +65,10 @@ export type OAuthPrompt = {
65
65
  };
66
66
 
67
67
  export type OAuthAuthInfo = {
68
+ /** URL presented for copying (for example a hosted callback suitable for headless use). */
68
69
  url: string;
70
+ /** Optional URL opened automatically when it differs from the displayed URL. */
71
+ openUrl?: string;
69
72
  instructions?: string;
70
73
  kind?: "browser" | "device";
71
74
  userCode?: string;