@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 +2 -2
- package/src/auth-storage.ts +1 -1
- package/src/cli.ts +1 -0
- package/src/provider-models/openai-compat.ts +3 -1
- package/src/utils/oauth/anthropic.ts +230 -105
- package/src/utils/oauth/types.ts +3 -0
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.
|
|
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.
|
|
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",
|
package/src/auth-storage.ts
CHANGED
|
@@ -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
|
@@ -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 = (
|
|
5
|
+
const decode = (value: string) => atob(value);
|
|
9
6
|
const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
|
|
10
|
-
const AUTHORIZE_URL = "https://claude.
|
|
11
|
-
const TOKEN_URL = "https://
|
|
12
|
-
const
|
|
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 = "
|
|
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
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
export interface AnthropicOAuthFlowOptions {
|
|
16
|
+
timeoutMs?: number;
|
|
17
|
+
}
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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 (
|
|
73
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
109
|
-
|
|
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
|
}
|
package/src/utils/oauth/types.ts
CHANGED
|
@@ -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;
|