@oh-my-pi/pi-ai 6.7.67 → 6.7.670

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "6.7.67",
3
+ "version": "6.7.670",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -29,21 +29,20 @@ import { sanitizeSurrogates } from "../utils/sanitize-unicode";
29
29
 
30
30
  import { transformMessages } from "./transform-messages";
31
31
 
32
- // Stealth mode: Mimic Claude Code headers while avoiding tool name collisions.
33
- export const claudeCodeVersion = "2.1.2";
32
+ // Stealth mode: Mimic Claude Code headers and tool prefixing.
33
+ export const claudeCodeVersion = "1.0.83";
34
34
  export const claudeToolPrefix = "proxy_";
35
35
  export const claudeCodeSystemInstruction = "You are Claude Code, Anthropic's official CLI for Claude.";
36
36
  export const claudeCodeHeaders = {
37
- "anthropic-version": "2023-06-01",
38
- "x-stainless-helper-method": "stream",
39
- "x-stainless-retry-count": "0",
40
- "x-stainless-runtime-version": "v24.3.0",
41
- "x-stainless-package-version": "0.55.1",
42
- "x-stainless-runtime": "node",
43
- "x-stainless-lang": "js",
44
- "x-stainless-arch": "arm64",
45
- "x-stainless-os": "MacOS",
46
- "x-stainless-timeout": "60",
37
+ "X-Stainless-Helper-Method": "stream",
38
+ "X-Stainless-Retry-Count": "0",
39
+ "X-Stainless-Runtime-Version": "v24.3.0",
40
+ "X-Stainless-Package-Version": "0.55.1",
41
+ "X-Stainless-Runtime": "node",
42
+ "X-Stainless-Lang": "js",
43
+ "X-Stainless-Arch": "arm64",
44
+ "X-Stainless-Os": "MacOS",
45
+ "X-Stainless-Timeout": "60",
47
46
  } as const;
48
47
 
49
48
  export const applyClaudeToolPrefix = (name: string) => {
@@ -60,45 +59,18 @@ export const stripClaudeToolPrefix = (name: string) => {
60
59
  return name.slice(claudeToolPrefix.length);
61
60
  };
62
61
 
63
- // Claude Code 2.x tool names (canonical casing)
64
- // Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md
65
- // To update: https://github.com/badlogic/cchistory
66
- const claudeCodeTools = [
67
- "Read",
68
- "Write",
69
- "Edit",
70
- "Bash",
71
- "Grep",
72
- "Glob",
73
- "AskUserQuestion",
74
- "EnterPlanMode",
75
- "ExitPlanMode",
76
- "KillShell",
77
- "NotebookEdit",
78
- "Skill",
79
- "Task",
80
- "TaskOutput",
81
- "TodoWrite",
82
- "WebFetch",
83
- "WebSearch",
62
+ const claudeCodeBetaDefaults = [
63
+ "claude-code-20250219",
64
+ "oauth-2025-04-20",
65
+ "interleaved-thinking-2025-05-14",
66
+ "fine-grained-tool-streaming-2025-05-14",
84
67
  ];
85
68
 
86
- const ccToolLookup = new Map(claudeCodeTools.map((t) => [t.toLowerCase(), t]));
69
+ // Prefix tool names for OAuth traffic.
70
+ const toClaudeCodeName = (name: string) => applyClaudeToolPrefix(name);
87
71
 
88
- // Convert tool name to CC canonical casing if it matches (case-insensitive), fallback to prefix
89
- const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase()) ?? applyClaudeToolPrefix(name);
90
-
91
- // Convert CC tool name back to original, checking provided tools for case-insensitive match
92
- const fromClaudeCodeName = (name: string, tools?: Tool[]) => {
93
- // First try to find by case-insensitive match in provided tools
94
- if (tools && tools.length > 0) {
95
- const lowerName = name.toLowerCase();
96
- const matchedTool = tools.find((tool) => tool.name.toLowerCase() === lowerName);
97
- if (matchedTool) return matchedTool.name;
98
- }
99
- // Fall back to stripping prefix if no match found
100
- return stripClaudeToolPrefix(name);
101
- };
72
+ // Strip Claude Code tool prefix on response.
73
+ const fromClaudeCodeName = (name: string) => stripClaudeToolPrefix(name);
102
74
 
103
75
  /**
104
76
  * Convert content blocks to Anthropic API format
@@ -189,7 +161,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
189
161
  try {
190
162
  const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
191
163
  const extraBetas = normalizeExtraBetas(options?.betas);
192
- const { client, isOAuthToken } = createClient(model, apiKey, options?.interleavedThinking ?? true, extraBetas);
164
+ const { client, isOAuthToken } = createClient(model, apiKey, extraBetas, true);
193
165
  const params = buildParams(model, context, isOAuthToken, options);
194
166
  const anthropicStream = client.messages.stream({ ...params, stream: true }, { signal: options?.signal });
195
167
  stream.push({ type: "start", partial: output });
@@ -231,9 +203,7 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
231
203
  const block: Block = {
232
204
  type: "toolCall",
233
205
  id: event.content_block.id,
234
- name: isOAuthToken
235
- ? fromClaudeCodeName(event.content_block.name, context.tools)
236
- : event.content_block.name,
206
+ name: isOAuthToken ? fromClaudeCodeName(event.content_block.name) : event.content_block.name,
237
207
  arguments: event.content_block.input as Record<string, any>,
238
208
  partialJson: "",
239
209
  index: event.index,
@@ -358,6 +328,16 @@ function isOAuthToken(apiKey: string): boolean {
358
328
  return apiKey.includes("sk-ant-oat");
359
329
  }
360
330
 
331
+ function isAnthropicBaseUrl(baseUrl?: string): boolean {
332
+ if (!baseUrl) return true;
333
+ try {
334
+ const url = new URL(baseUrl);
335
+ return url.protocol === "https:" && url.hostname === "api.anthropic.com";
336
+ } catch {
337
+ return false;
338
+ }
339
+ }
340
+
361
341
  export function normalizeExtraBetas(betas?: string[] | string): string[] {
362
342
  if (!betas) return [];
363
343
  const raw = Array.isArray(betas) ? betas : betas.split(",");
@@ -378,84 +358,105 @@ export function buildBetaHeader(baseBetas: string[], extraBetas: string[]): stri
378
358
  return result.join(",");
379
359
  }
380
360
 
361
+ export type AnthropicHeaderOptions = {
362
+ apiKey: string;
363
+ baseUrl?: string;
364
+ isOAuth?: boolean;
365
+ extraBetas?: string[];
366
+ stream?: boolean;
367
+ modelHeaders?: Record<string, string>;
368
+ };
369
+
370
+ export function buildAnthropicHeaders(options: AnthropicHeaderOptions): Record<string, string> {
371
+ const oauthToken = options.isOAuth ?? isOAuthToken(options.apiKey);
372
+ const extraBetas = options.extraBetas ?? [];
373
+ const stream = options.stream ?? false;
374
+ const betaHeader = buildBetaHeader(claudeCodeBetaDefaults, extraBetas);
375
+ const acceptHeader = stream ? "text/event-stream" : "application/json";
376
+ const enforcedHeaderKeys = new Set(
377
+ [
378
+ ...Object.keys(claudeCodeHeaders),
379
+ "Accept",
380
+ "Accept-Encoding",
381
+ "Connection",
382
+ "Content-Type",
383
+ "Anthropic-Version",
384
+ "Anthropic-Dangerous-Direct-Browser-Access",
385
+ "Anthropic-Beta",
386
+ "User-Agent",
387
+ "X-App",
388
+ "Authorization",
389
+ "X-Api-Key",
390
+ ].map((key) => key.toLowerCase()),
391
+ );
392
+ const modelHeaders = Object.fromEntries(
393
+ Object.entries(options.modelHeaders ?? {}).filter(([key]) => !enforcedHeaderKeys.has(key.toLowerCase())),
394
+ );
395
+ const headers: Record<string, string> = {
396
+ ...modelHeaders,
397
+ ...claudeCodeHeaders,
398
+ Accept: acceptHeader,
399
+ "Accept-Encoding": "gzip, deflate, br, zstd",
400
+ Connection: "keep-alive",
401
+ "Content-Type": "application/json",
402
+ "Anthropic-Version": "2023-06-01",
403
+ "Anthropic-Dangerous-Direct-Browser-Access": "true",
404
+ "Anthropic-Beta": betaHeader,
405
+ "User-Agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
406
+ "X-App": "cli",
407
+ };
408
+
409
+ if (oauthToken || !isAnthropicBaseUrl(options.baseUrl)) {
410
+ headers.Authorization = `Bearer ${options.apiKey}`;
411
+ } else {
412
+ headers["X-Api-Key"] = options.apiKey;
413
+ }
414
+
415
+ return headers;
416
+ }
417
+
381
418
  function createClient(
382
419
  model: Model<"anthropic-messages">,
383
420
  apiKey: string,
384
- interleavedThinking: boolean,
385
421
  extraBetas: string[],
422
+ stream: boolean,
386
423
  ): { client: Anthropic; isOAuthToken: boolean } {
387
424
  const oauthToken = isOAuthToken(apiKey);
388
425
 
389
- // Base betas required for Claude Code compatibility
390
- const baseBetas = oauthToken
391
- ? ["claude-code-20250219", "oauth-2025-04-20", "fine-grained-tool-streaming-2025-05-14"]
392
- : ["fine-grained-tool-streaming-2025-05-14"];
393
-
394
- // Add interleaved thinking if requested
395
426
  const mergedBetas: string[] = [];
396
- if (interleavedThinking) {
397
- mergedBetas.push("interleaved-thinking-2025-05-14");
398
- }
399
-
400
- // Include any betas from model headers
401
427
  const modelBeta = model.headers?.["anthropic-beta"];
402
428
  if (modelBeta) {
403
429
  mergedBetas.push(...normalizeExtraBetas(modelBeta));
404
430
  }
405
-
406
- // Include any betas passed via options
407
431
  if (extraBetas.length > 0) {
408
432
  mergedBetas.push(...extraBetas);
409
433
  }
410
434
 
411
- const betaHeader = buildBetaHeader(baseBetas, mergedBetas);
412
-
413
- if (oauthToken) {
414
- // Stealth mode: Mimic Claude Code's headers exactly
415
- const defaultHeaders = {
416
- accept: "application/json",
417
- "anthropic-dangerous-direct-browser-access": "true",
418
- "anthropic-beta": betaHeader,
419
- "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
420
- "x-app": "cli",
421
- ...claudeCodeHeaders,
422
- ...(model.headers || {}),
423
- };
424
- // Don't duplicate anthropic-beta from model.headers
425
- delete (defaultHeaders as Record<string, string>)["anthropic-beta"];
426
- (defaultHeaders as Record<string, string>)["anthropic-beta"] = betaHeader;
427
-
428
- const client = new Anthropic({
429
- apiKey: null,
430
- authToken: apiKey,
431
- baseURL: model.baseUrl,
432
- defaultHeaders,
433
- dangerouslyAllowBrowser: true,
434
- });
435
-
436
- return { client, isOAuthToken: true };
437
- }
438
-
439
- const defaultHeaders = {
440
- accept: "application/json",
441
- "anthropic-dangerous-direct-browser-access": "true",
442
- "anthropic-beta": betaHeader,
443
- "user-agent": `claude-cli/${claudeCodeVersion} (external, cli)`,
444
- "x-app": "cli",
445
- ...claudeCodeHeaders,
446
- ...(model.headers || {}),
447
- };
448
- // Ensure our beta header takes precedence
449
- (defaultHeaders as Record<string, string>)["anthropic-beta"] = betaHeader;
450
-
451
- const client = new Anthropic({
435
+ const defaultHeadersBase = buildAnthropicHeaders({
452
436
  apiKey,
437
+ baseUrl: model.baseUrl,
438
+ isOAuth: oauthToken,
439
+ extraBetas: mergedBetas,
440
+ stream,
441
+ modelHeaders: model.headers,
442
+ });
443
+
444
+ const clientOptions: ConstructorParameters<typeof Anthropic>[0] = {
453
445
  baseURL: model.baseUrl,
454
446
  dangerouslyAllowBrowser: true,
455
- defaultHeaders,
456
- });
447
+ defaultHeaders: defaultHeadersBase,
448
+ };
449
+
450
+ if (oauthToken || !isAnthropicBaseUrl(model.baseUrl)) {
451
+ clientOptions.apiKey = null;
452
+ clientOptions.authToken = apiKey;
453
+ } else {
454
+ clientOptions.apiKey = apiKey;
455
+ }
457
456
 
458
- return { client, isOAuthToken: false };
457
+ const client = new Anthropic(clientOptions);
458
+
459
+ return { client, isOAuthToken: oauthToken };
459
460
  }
460
461
 
461
462
  export type AnthropicSystemBlock = {
@@ -464,6 +465,14 @@ export type AnthropicSystemBlock = {
464
465
  cache_control?: { type: "ephemeral" };
465
466
  };
466
467
 
468
+ type CacheControlBlock = {
469
+ cache_control?: { type: "ephemeral" };
470
+ };
471
+
472
+ type CacheControlMode = "none" | "toolBlocks" | "userText";
473
+
474
+ const cacheControlEphemeral = { type: "ephemeral" as const };
475
+
467
476
  type SystemBlockOptions = {
468
477
  includeClaudeCodeInstruction?: boolean;
469
478
  includeCacheControl?: boolean;
@@ -537,9 +546,11 @@ function buildParams(
537
546
  isOAuthToken: boolean,
538
547
  options?: AnthropicOptions,
539
548
  ): MessageCreateParamsStreaming {
549
+ const hasTools = Boolean(context.tools?.length);
550
+ const cacheControlMode = resolveCacheControlMode(context.messages, hasTools && isOAuthToken);
540
551
  const params: MessageCreateParamsStreaming = {
541
552
  model: model.id,
542
- messages: convertMessages(context.messages, model, isOAuthToken),
553
+ messages: convertMessages(context.messages, model, isOAuthToken, cacheControlMode),
543
554
  max_tokens: options?.maxTokens || (model.maxTokens / 3) | 0,
544
555
  stream: true,
545
556
  };
@@ -547,7 +558,7 @@ function buildParams(
547
558
  const includeClaudeCodeSystem = !model.id.startsWith("claude-3-5-haiku");
548
559
  const systemBlocks = buildAnthropicSystemBlocks(context.systemPrompt, {
549
560
  includeClaudeCodeInstruction: includeClaudeCodeSystem,
550
- includeCacheControl: true,
561
+ includeCacheControl: cacheControlMode !== "none",
551
562
  });
552
563
  if (systemBlocks) {
553
564
  params.system = systemBlocks;
@@ -594,12 +605,33 @@ function sanitizeToolCallId(id: string): string {
594
605
  return id.replace(/[^a-zA-Z0-9_-]/g, "_");
595
606
  }
596
607
 
608
+ function resolveCacheControlMode(messages: Message[], includeCacheControl: boolean): CacheControlMode {
609
+ if (!includeCacheControl) return "none";
610
+
611
+ for (const message of messages) {
612
+ if (message.role === "toolResult") return "toolBlocks";
613
+ if (message.role === "assistant") {
614
+ const hasToolCall = message.content.some((block) => block.type === "toolCall");
615
+ if (hasToolCall) return "toolBlocks";
616
+ }
617
+ }
618
+
619
+ return "userText";
620
+ }
621
+
597
622
  function convertMessages(
598
623
  messages: Message[],
599
624
  model: Model<"anthropic-messages">,
600
625
  isOAuthToken: boolean,
626
+ cacheControlMode: CacheControlMode,
601
627
  ): MessageParam[] {
602
628
  const params: MessageParam[] = [];
629
+ const applyToolCacheControl = cacheControlMode === "toolBlocks";
630
+ const applyUserTextCacheControl = cacheControlMode === "userText";
631
+ const withCacheControl = <T extends object>(block: T, enabled: boolean): T | (T & CacheControlBlock) => {
632
+ if (!enabled) return block;
633
+ return { ...block, cache_control: cacheControlEphemeral };
634
+ };
603
635
 
604
636
  // Transform messages for cross-provider compatibility
605
637
  const transformedMessages = transformMessages(messages, model);
@@ -610,28 +642,47 @@ function convertMessages(
610
642
  if (msg.role === "user") {
611
643
  if (typeof msg.content === "string") {
612
644
  if (msg.content.trim().length > 0) {
613
- params.push({
614
- role: "user",
615
- content: sanitizeSurrogates(msg.content),
616
- });
645
+ const text = sanitizeSurrogates(msg.content);
646
+ if (applyUserTextCacheControl) {
647
+ const blocks: Array<ContentBlockParam & CacheControlBlock> = [
648
+ withCacheControl(
649
+ {
650
+ type: "text",
651
+ text,
652
+ },
653
+ true,
654
+ ),
655
+ ];
656
+ params.push({
657
+ role: "user",
658
+ content: blocks,
659
+ });
660
+ } else {
661
+ params.push({
662
+ role: "user",
663
+ content: text,
664
+ });
665
+ }
617
666
  }
618
667
  } else {
619
- const blocks: ContentBlockParam[] = msg.content.map((item) => {
668
+ const blocks: Array<ContentBlockParam & CacheControlBlock> = msg.content.map((item) => {
620
669
  if (item.type === "text") {
621
- return {
622
- type: "text",
623
- text: sanitizeSurrogates(item.text),
624
- };
625
- } else {
626
- return {
627
- type: "image",
628
- source: {
629
- type: "base64",
630
- media_type: item.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
631
- data: item.data,
670
+ return withCacheControl(
671
+ {
672
+ type: "text",
673
+ text: sanitizeSurrogates(item.text),
632
674
  },
633
- };
675
+ applyUserTextCacheControl,
676
+ );
634
677
  }
678
+ return {
679
+ type: "image",
680
+ source: {
681
+ type: "base64",
682
+ media_type: item.mimeType as "image/jpeg" | "image/png" | "image/gif" | "image/webp",
683
+ data: item.data,
684
+ },
685
+ };
635
686
  });
636
687
  let filteredBlocks = !model?.input.includes("image") ? blocks.filter((b) => b.type !== "image") : blocks;
637
688
  filteredBlocks = filteredBlocks.filter((b) => {
@@ -647,7 +698,7 @@ function convertMessages(
647
698
  });
648
699
  }
649
700
  } else if (msg.role === "assistant") {
650
- const blocks: ContentBlockParam[] = [];
701
+ const blocks: Array<ContentBlockParam & CacheControlBlock> = [];
651
702
 
652
703
  for (const block of msg.content) {
653
704
  if (block.type === "text") {
@@ -674,12 +725,17 @@ function convertMessages(
674
725
  });
675
726
  }
676
727
  } else if (block.type === "toolCall") {
677
- blocks.push({
678
- type: "tool_use",
679
- id: sanitizeToolCallId(block.id),
680
- name: isOAuthToken ? toClaudeCodeName(block.name) : block.name,
681
- input: block.arguments,
682
- });
728
+ blocks.push(
729
+ withCacheControl(
730
+ {
731
+ type: "tool_use",
732
+ id: sanitizeToolCallId(block.id),
733
+ name: isOAuthToken ? toClaudeCodeName(block.name) : block.name,
734
+ input: block.arguments,
735
+ },
736
+ applyToolCacheControl,
737
+ ),
738
+ );
683
739
  }
684
740
  }
685
741
  if (blocks.length === 0) continue;
@@ -689,26 +745,36 @@ function convertMessages(
689
745
  });
690
746
  } else if (msg.role === "toolResult") {
691
747
  // Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint
692
- const toolResults: ContentBlockParam[] = [];
748
+ const toolResults: Array<ContentBlockParam & CacheControlBlock> = [];
693
749
 
694
750
  // Add the current tool result
695
- toolResults.push({
696
- type: "tool_result",
697
- tool_use_id: sanitizeToolCallId(msg.toolCallId),
698
- content: convertContentBlocks(msg.content),
699
- is_error: msg.isError,
700
- });
751
+ toolResults.push(
752
+ withCacheControl(
753
+ {
754
+ type: "tool_result",
755
+ tool_use_id: sanitizeToolCallId(msg.toolCallId),
756
+ content: convertContentBlocks(msg.content),
757
+ is_error: msg.isError,
758
+ },
759
+ applyToolCacheControl,
760
+ ),
761
+ );
701
762
 
702
763
  // Look ahead for consecutive toolResult messages
703
764
  let j = i + 1;
704
765
  while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") {
705
766
  const nextMsg = transformedMessages[j] as ToolResultMessage; // We know it's a toolResult
706
- toolResults.push({
707
- type: "tool_result",
708
- tool_use_id: sanitizeToolCallId(nextMsg.toolCallId),
709
- content: convertContentBlocks(nextMsg.content),
710
- is_error: nextMsg.isError,
711
- });
767
+ toolResults.push(
768
+ withCacheControl(
769
+ {
770
+ type: "tool_result",
771
+ tool_use_id: sanitizeToolCallId(nextMsg.toolCallId),
772
+ content: convertContentBlocks(nextMsg.content),
773
+ is_error: nextMsg.isError,
774
+ },
775
+ applyToolCacheControl,
776
+ ),
777
+ );
712
778
  j++;
713
779
  }
714
780
 
@@ -723,23 +789,6 @@ function convertMessages(
723
789
  }
724
790
  }
725
791
 
726
- // Add cache_control to the last user message to cache conversation history
727
- if (params.length > 0) {
728
- const lastMessage = params[params.length - 1];
729
- if (lastMessage.role === "user") {
730
- // Add cache control to the last content block
731
- if (Array.isArray(lastMessage.content)) {
732
- const lastBlock = lastMessage.content[lastMessage.content.length - 1];
733
- if (
734
- lastBlock &&
735
- (lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result")
736
- ) {
737
- (lastBlock as any).cache_control = { type: "ephemeral" };
738
- }
739
- }
740
- }
741
- }
742
-
743
792
  return params;
744
793
  }
745
794
 
@@ -9,9 +9,17 @@ const decode = (s: string) => atob(s);
9
9
  const CLIENT_ID = decode("OWQxYzI1MGEtZTYxYi00NGQ5LTg4ZWQtNTk0NGQxOTYyZjVl");
10
10
  const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
11
11
  const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
12
- const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
12
+ const REDIRECT_URI = "http://localhost:54545/callback";
13
13
  const SCOPES = "org:create_api_key user:profile user:inference";
14
14
 
15
+ function generateState(): string {
16
+ const bytes = new Uint8Array(16);
17
+ crypto.getRandomValues(bytes);
18
+ return Array.from(bytes)
19
+ .map((value) => value.toString(16).padStart(2, "0"))
20
+ .join("");
21
+ }
22
+
15
23
  function parseAuthCode(input: string): { code: string; state?: string } {
16
24
  const trimmed = input.trim();
17
25
  if (!trimmed) return { code: "" };
@@ -47,6 +55,7 @@ export async function loginAnthropic(
47
55
  onPromptCode: () => Promise<string>,
48
56
  ): Promise<OAuthCredentials> {
49
57
  const { verifier, challenge } = await generatePKCE();
58
+ const state = generateState();
50
59
 
51
60
  // Build authorization URL
52
61
  const authParams = new URLSearchParams({
@@ -57,7 +66,7 @@ export async function loginAnthropic(
57
66
  scope: SCOPES,
58
67
  code_challenge: challenge,
59
68
  code_challenge_method: "S256",
60
- state: verifier,
69
+ state,
61
70
  });
62
71
 
63
72
  const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`;
@@ -67,19 +76,21 @@ export async function loginAnthropic(
67
76
 
68
77
  // Wait for user to paste authorization code (format: code#state)
69
78
  const authCode = await onPromptCode();
70
- const { code, state } = parseAuthCode(authCode);
79
+ const { code, state: parsedState } = parseAuthCode(authCode);
80
+ const requestState = parsedState ?? state;
71
81
 
72
82
  // Exchange code for tokens
73
83
  const tokenResponse = await fetch(TOKEN_URL, {
74
84
  method: "POST",
75
85
  headers: {
76
86
  "Content-Type": "application/json",
87
+ Accept: "application/json",
77
88
  },
78
89
  body: JSON.stringify({
79
90
  grant_type: "authorization_code",
80
91
  client_id: CLIENT_ID,
81
92
  code,
82
- ...(state ? { state } : {}),
93
+ state: requestState,
83
94
  redirect_uri: REDIRECT_URI,
84
95
  code_verifier: verifier,
85
96
  }),
@@ -113,7 +124,7 @@ export async function loginAnthropic(
113
124
  export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
114
125
  const response = await fetch(TOKEN_URL, {
115
126
  method: "POST",
116
- headers: { "Content-Type": "application/json" },
127
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
117
128
  body: JSON.stringify({
118
129
  grant_type: "refresh_token",
119
130
  client_id: CLIENT_ID,
@@ -20,7 +20,7 @@ function base64urlEncode(bytes: Uint8Array): string {
20
20
  */
21
21
  export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
22
22
  // Generate random verifier
23
- const verifierBytes = new Uint8Array(32);
23
+ const verifierBytes = new Uint8Array(96);
24
24
  crypto.getRandomValues(verifierBytes);
25
25
  const verifier = base64urlEncode(verifierBytes);
26
26