@gotgenes/pi-anthropic-auth 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -57,6 +57,34 @@ pnpm run build # compile
57
57
  pi -e /absolute/path/to/pi-anthropic-auth/dist/index.js
58
58
  ```
59
59
 
60
+ ### Debug Logging
61
+
62
+ Set `PI_ANTHROPIC_AUTH_DEBUG` to enable structured debug logs from the OAuth shaping layer.
63
+
64
+ Modes:
65
+
66
+ - `PI_ANTHROPIC_AUTH_DEBUG=all` — log all Anthropic OAuth shaping events
67
+ - `PI_ANTHROPIC_AUTH_DEBUG=tool-use` — log only requests that include `tool_use`
68
+
69
+ Example:
70
+
71
+ ```bash
72
+ PI_ANTHROPIC_AUTH_DEBUG=tool-use \
73
+ pi \
74
+ --model anthropic/claude-haiku-4-5 \
75
+ --no-session \
76
+ --tools read,grep,find,ls \
77
+ -e /absolute/path/to/pi-anthropic-auth/src/index.ts \
78
+ -p "How many lines are in @AGENTS.md ?"
79
+ ```
80
+
81
+ ## Similar Projects
82
+
83
+ - [opencode-anthropic-auth](https://github.com/ex-machina-co/opencode-anthropic-auth/) — Anthropic OAuth compatibility work for [OpenCode](https://opencode.ai/).
84
+ - [pi-anthropic-oauth](https://github.com/leohenon/pi-anthropic-oauth) — a Pi extension that takes a fuller provider-override approach.
85
+
86
+ For notes on how this project compares to similar work, see [docs/comparison-to-similar-projects.md](docs/comparison-to-similar-projects.md).
87
+
60
88
  ## Acknowledgments
61
89
 
62
90
  This project was inspired by [opencode-anthropic-auth](https://github.com/ex-machina-co/opencode-anthropic-auth/), which solved the same Anthropic OAuth compatibility problem for [OpenCode](https://opencode.ai/).
@@ -5,6 +5,14 @@
5
5
  * preamble so it can be replaced with the minimal neutral prompt.
6
6
  */
7
7
  export declare const PI_DEFAULT_PROMPT_PREFIX = "You are an expert coding assistant operating inside pi, a coding agent harness.";
8
+ /**
9
+ * Final line of Pi's built-in default system prompt preamble.
10
+ *
11
+ * Used to replace the entire Pi-generated preamble body with the minimal
12
+ * neutral Anthropic OAuth prompt while preserving anything appended after the
13
+ * preamble (project context, skills, and date/cwd footer).
14
+ */
15
+ export declare const PI_DEFAULT_PROMPT_TERMINATOR = "- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)";
8
16
  /**
9
17
  * Prefix of the minimal neutral Anthropic OAuth system prompt.
10
18
  *
@@ -43,3 +51,28 @@ export declare const BILLING_HEADER_SALT = "59cf53e54c78";
43
51
  export declare const BILLING_HEADER_POSITIONS: readonly [4, 7, 20];
44
52
  /** Entrypoint identifier included in the billing header. */
45
53
  export declare const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
54
+ /**
55
+ * Strings whose presence in a paragraph marks it as Pi-specific and droppable.
56
+ *
57
+ * Each entry is checked with `paragraph.includes(anchor)`.
58
+ */
59
+ export declare const PARAGRAPH_REMOVAL_ANCHORS: readonly string[];
60
+ /**
61
+ * Inline text replacements applied after paragraph removal.
62
+ *
63
+ * These handle known Anthropic classifier trigger phrases that may appear
64
+ * in paragraphs we want to keep. Each rule is applied with `replaceAll`.
65
+ *
66
+ * The "Here is some useful information..." phrase was isolated by
67
+ * `opencode-anthropic-auth` via sliding-window bisection of a 10KB failing
68
+ * prompt. When it reaches Anthropic combined with typical agent context,
69
+ * /v1/messages responds with a 400 disguised as "You're out of extra usage."
70
+ * Replacing the word "useful" is enough to unblock the request.
71
+ *
72
+ * We don't currently emit this phrase, but it's included as a documented
73
+ * future risk per Issue #10.
74
+ */
75
+ export declare const TEXT_REPLACEMENTS: readonly {
76
+ match: string;
77
+ replacement: string;
78
+ }[];
package/dist/constants.js CHANGED
@@ -5,6 +5,14 @@
5
5
  * preamble so it can be replaced with the minimal neutral prompt.
6
6
  */
7
7
  export const PI_DEFAULT_PROMPT_PREFIX = "You are an expert coding assistant operating inside pi, a coding agent harness.";
8
+ /**
9
+ * Final line of Pi's built-in default system prompt preamble.
10
+ *
11
+ * Used to replace the entire Pi-generated preamble body with the minimal
12
+ * neutral Anthropic OAuth prompt while preserving anything appended after the
13
+ * preamble (project context, skills, and date/cwd footer).
14
+ */
15
+ export const PI_DEFAULT_PROMPT_TERMINATOR = "- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)";
8
16
  /**
9
17
  * Prefix of the minimal neutral Anthropic OAuth system prompt.
10
18
  *
@@ -60,3 +68,49 @@ export const BILLING_HEADER_SALT = "59cf53e54c78";
60
68
  export const BILLING_HEADER_POSITIONS = [4, 7, 20];
61
69
  /** Entrypoint identifier included in the billing header. */
62
70
  export const CLAUDE_CODE_ENTRYPOINT = "sdk-cli";
71
+ // ---------------------------------------------------------------------------
72
+ // Anchor-driven sanitizer constants
73
+ //
74
+ // Used by the system prompt sanitizer to remove Pi-specific paragraphs
75
+ // (identity, documentation references, filler) while preserving extension-
76
+ // contributed content (tool snippets, guidelines, appended content).
77
+ //
78
+ // A paragraph is any text between blank lines. If a paragraph contains any
79
+ // anchor string, it is dropped entirely. This is resilient to upstream
80
+ // rewording — as long as the anchor still appears somewhere in the paragraph,
81
+ // removal works regardless of surrounding text changes.
82
+ // ---------------------------------------------------------------------------
83
+ /**
84
+ * Strings whose presence in a paragraph marks it as Pi-specific and droppable.
85
+ *
86
+ * Each entry is checked with `paragraph.includes(anchor)`.
87
+ */
88
+ export const PARAGRAPH_REMOVAL_ANCHORS = [
89
+ // Pi identity sentence
90
+ "operating inside pi, a coding agent harness",
91
+ // Pi-specific filler about custom tools
92
+ "In addition to the tools above",
93
+ // Pi documentation block — references Pi-specific docs/paths
94
+ "Pi documentation (read only when the user asks about pi itself",
95
+ ];
96
+ /**
97
+ * Inline text replacements applied after paragraph removal.
98
+ *
99
+ * These handle known Anthropic classifier trigger phrases that may appear
100
+ * in paragraphs we want to keep. Each rule is applied with `replaceAll`.
101
+ *
102
+ * The "Here is some useful information..." phrase was isolated by
103
+ * `opencode-anthropic-auth` via sliding-window bisection of a 10KB failing
104
+ * prompt. When it reaches Anthropic combined with typical agent context,
105
+ * /v1/messages responds with a 400 disguised as "You're out of extra usage."
106
+ * Replacing the word "useful" is enough to unblock the request.
107
+ *
108
+ * We don't currently emit this phrase, but it's included as a documented
109
+ * future risk per Issue #10.
110
+ */
111
+ export const TEXT_REPLACEMENTS = [
112
+ {
113
+ match: "Here is some useful information about the environment you are running in:",
114
+ replacement: "Environment context you are running in:",
115
+ },
116
+ ];
@@ -0,0 +1,3 @@
1
+ export declare function isDebugEnabled(): boolean;
2
+ export declare function isToolUseOnlyDebugEnabled(): boolean;
3
+ export declare function debugLog(scope: string, data: unknown): void;
package/dist/debug.js ADDED
@@ -0,0 +1,32 @@
1
+ function getDebugMode(value) {
2
+ if (!value)
3
+ return "off";
4
+ switch (value.trim().toLowerCase()) {
5
+ case "1":
6
+ case "true":
7
+ case "yes":
8
+ case "on":
9
+ case "debug":
10
+ case "all":
11
+ return "all";
12
+ case "tool":
13
+ case "tools":
14
+ case "tool-use":
15
+ case "tool_use":
16
+ return "tool-use";
17
+ default:
18
+ return "off";
19
+ }
20
+ }
21
+ export function isDebugEnabled() {
22
+ return getDebugMode(process.env.PI_ANTHROPIC_AUTH_DEBUG) !== "off";
23
+ }
24
+ export function isToolUseOnlyDebugEnabled() {
25
+ return getDebugMode(process.env.PI_ANTHROPIC_AUTH_DEBUG) === "tool-use";
26
+ }
27
+ export function debugLog(scope, data) {
28
+ if (!isDebugEnabled()) {
29
+ return;
30
+ }
31
+ console.error(`[pi-anthropic-auth][debug] ${scope} ${JSON.stringify(data)}`);
32
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { BILLING_HEADER_POSITIONS, BILLING_HEADER_SALT, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_IDENTITY_PREFIX, CLAUDE_CODE_VERSION, MINIMAL_ANTHROPIC_OAUTH_PROMPT_PREFIX, } from "./constants.js";
3
+ import { debugLog, isToolUseOnlyDebugEnabled } from "./debug.js";
3
4
  import { shapeSystemBlocks } from "./system-prompt-shaping.js";
4
- const ANTHROPIC_OAUTH_BETAS = ["claude-code-20250219", "oauth-2025-04-20"];
5
5
  function isRecord(value) {
6
6
  return value !== null && typeof value === "object" && !Array.isArray(value);
7
7
  }
@@ -90,13 +90,6 @@ function prependBillingHeader(system, messages) {
90
90
  const billingBlock = { type: "text", text: billingHeader };
91
91
  return [billingBlock, ...systemBlocks];
92
92
  }
93
- function mergeAnthropicBetas(betaHeader) {
94
- const existing = (betaHeader ?? "")
95
- .split(",")
96
- .map((value) => value.trim())
97
- .filter(Boolean);
98
- return [...new Set([...ANTHROPIC_OAUTH_BETAS, ...existing])].join(",");
99
- }
100
93
  /**
101
94
  * Splits assistant messages that interleave text and tool_use blocks.
102
95
  *
@@ -127,6 +120,36 @@ function splitAssistantToolUseTrailingContent(messages) {
127
120
  ];
128
121
  });
129
122
  }
123
+ function getToolDefinitionNames(payload) {
124
+ const tools = payload.tools;
125
+ if (!Array.isArray(tools)) {
126
+ return [];
127
+ }
128
+ return tools
129
+ .map((tool) => isRecord(tool) && typeof tool.name === "string" ? tool.name : undefined)
130
+ .filter((name) => typeof name === "string");
131
+ }
132
+ function getToolUseNames(messages) {
133
+ return messages.flatMap((message) => {
134
+ if (!Array.isArray(message.content)) {
135
+ return [];
136
+ }
137
+ return message.content
138
+ .map((block) => block?.type === "tool_use" && typeof block.name === "string"
139
+ ? block.name
140
+ : undefined)
141
+ .filter((name) => typeof name === "string");
142
+ });
143
+ }
144
+ function countAssistantMessages(messages) {
145
+ return messages.filter((message) => message.role === "assistant").length;
146
+ }
147
+ function shouldLogRequestDebug(messages) {
148
+ if (!isToolUseOnlyDebugEnabled()) {
149
+ return true;
150
+ }
151
+ return getToolUseNames(messages).length > 0;
152
+ }
130
153
  export function shapeAnthropicOAuthPayload(payload) {
131
154
  if (!isAnthropicMessagesPayload(payload)) {
132
155
  return payload;
@@ -139,13 +162,28 @@ export function shapeAnthropicOAuthPayload(payload) {
139
162
  const shapedSystem = Array.isArray(payload.system)
140
163
  ? shapeSystemBlocks(payload.system)
141
164
  : payload.system;
142
- const shapedPayload = {
165
+ const finalSystem = prependBillingHeader(shapedSystem, normalizedMessages);
166
+ const toolUseNamesBefore = getToolUseNames(messages);
167
+ const toolUseNamesAfter = getToolUseNames(normalizedMessages);
168
+ if (shouldLogRequestDebug(messages)) {
169
+ debugLog("before-provider-request", {
170
+ model: payload.model,
171
+ systemBlockCountBefore: Array.isArray(payload.system)
172
+ ? payload.system.length
173
+ : 0,
174
+ systemBlockCountAfter: Array.isArray(finalSystem)
175
+ ? finalSystem.length
176
+ : 0,
177
+ assistantMessagesBefore: countAssistantMessages(messages),
178
+ assistantMessagesAfter: countAssistantMessages(normalizedMessages),
179
+ toolDefinitions: getToolDefinitionNames(payload),
180
+ toolUseNamesBefore,
181
+ toolUseNamesAfter,
182
+ });
183
+ }
184
+ return {
143
185
  ...payload,
144
186
  messages: normalizedMessages,
145
- system: prependBillingHeader(shapedSystem, normalizedMessages),
187
+ system: finalSystem,
146
188
  };
147
- shapedPayload["anthropic-beta"] = mergeAnthropicBetas(typeof payload["anthropic-beta"] === "string"
148
- ? payload["anthropic-beta"]
149
- : undefined);
150
- return shapedPayload;
151
189
  }
@@ -1,9 +1,40 @@
1
+ /**
2
+ * Reset the one-time terminator-missing warning latch. Exposed for tests.
3
+ */
4
+ export declare function _resetShapingWarnings(): void;
5
+ export type SanitizedSystemTextReport = {
6
+ text: string;
7
+ removedParagraphs: Array<{
8
+ anchor: string;
9
+ preview: string;
10
+ }>;
11
+ replacementMatches: string[];
12
+ };
13
+ /**
14
+ * Sanitize system prompt text by removing paragraphs containing known
15
+ * Pi-specific anchor strings and applying inline text replacements for
16
+ * known Anthropic classifier trigger phrases.
17
+ *
18
+ * A paragraph is any text between blank lines (`\n\n`).
19
+ *
20
+ * This approach is resilient to upstream rewording — as long as the anchor
21
+ * string still appears somewhere in the paragraph, removal works regardless
22
+ * of how the surrounding text changes.
23
+ */
24
+ export declare function sanitizeSystemTextWithReport(text: string): SanitizedSystemTextReport;
25
+ export declare function sanitizeSystemText(text: string): string;
1
26
  /**
2
27
  * Shape a system prompt string for Anthropic OAuth compatibility.
3
28
  *
4
- * Replaces Pi's verbose default preamble with a minimal neutral prompt while
5
- * preserving any project context that follows. Returns the original string
6
- * unchanged when Pi's default preamble is not detected.
29
+ * For the normal upstream Pi prompt shape, sanitize only the known preamble
30
+ * span and replace its identity paragraph with the minimal neutral prompt.
31
+ * This preserves downstream configuration/extension points embedded in the
32
+ * preamble (tool snippets and guideline bullets) while still stripping the
33
+ * Pi-specific identity, filler, and documentation paragraphs.
34
+ *
35
+ * If Pi's known preamble terminator drifts upstream, we fall back to slicing
36
+ * from `# Project Context`. If that section is also absent, we return the
37
+ * minimal prompt only.
7
38
  */
8
39
  export declare function shapeAnthropicOAuthSystemPrompt(systemPrompt: string): string;
9
40
  type TextBlock = {
@@ -1,4 +1,74 @@
1
- import { MINIMAL_ANTHROPIC_OAUTH_PROMPT, PI_DEFAULT_PROMPT_PREFIX, } from "./constants.js";
1
+ import { MINIMAL_ANTHROPIC_OAUTH_PROMPT, PARAGRAPH_REMOVAL_ANCHORS, PI_DEFAULT_PROMPT_PREFIX, PI_DEFAULT_PROMPT_TERMINATOR, TEXT_REPLACEMENTS, } from "./constants.js";
2
+ import { debugLog, isToolUseOnlyDebugEnabled } from "./debug.js";
3
+ let warnedTerminatorMissing = false;
4
+ function warnTerminatorMissingOnce() {
5
+ if (warnedTerminatorMissing) {
6
+ return;
7
+ }
8
+ warnedTerminatorMissing = true;
9
+ console.warn("[pi-anthropic-auth] Pi default preamble terminator not found; falling back to '# Project Context' anchor. " +
10
+ "Upstream Pi may have reworded its preamble — update PI_DEFAULT_PROMPT_TERMINATOR.");
11
+ }
12
+ /**
13
+ * Reset the one-time terminator-missing warning latch. Exposed for tests.
14
+ */
15
+ export function _resetShapingWarnings() {
16
+ warnedTerminatorMissing = false;
17
+ }
18
+ function previewParagraph(paragraph) {
19
+ return paragraph.replace(/\s+/g, " ").trim().slice(0, 140);
20
+ }
21
+ function shouldLogPromptDebug(report) {
22
+ if (!isToolUseOnlyDebugEnabled()) {
23
+ return true;
24
+ }
25
+ return (report.removedParagraphs.length === 0 &&
26
+ report.replacementMatches.length > 0);
27
+ }
28
+ /**
29
+ * Sanitize system prompt text by removing paragraphs containing known
30
+ * Pi-specific anchor strings and applying inline text replacements for
31
+ * known Anthropic classifier trigger phrases.
32
+ *
33
+ * A paragraph is any text between blank lines (`\n\n`).
34
+ *
35
+ * This approach is resilient to upstream rewording — as long as the anchor
36
+ * string still appears somewhere in the paragraph, removal works regardless
37
+ * of how the surrounding text changes.
38
+ */
39
+ export function sanitizeSystemTextWithReport(text) {
40
+ const paragraphs = text.split(/\n\n+/);
41
+ const removedParagraphs = [];
42
+ const filtered = paragraphs.filter((paragraph) => {
43
+ for (const anchor of PARAGRAPH_REMOVAL_ANCHORS) {
44
+ if (!paragraph.includes(anchor)) {
45
+ continue;
46
+ }
47
+ removedParagraphs.push({
48
+ anchor,
49
+ preview: previewParagraph(paragraph),
50
+ });
51
+ return false;
52
+ }
53
+ return true;
54
+ });
55
+ let result = filtered.join("\n\n");
56
+ const replacementMatches = [];
57
+ for (const rule of TEXT_REPLACEMENTS) {
58
+ if (result.includes(rule.match)) {
59
+ replacementMatches.push(rule.match);
60
+ }
61
+ result = result.replaceAll(rule.match, rule.replacement);
62
+ }
63
+ return {
64
+ text: result.trim(),
65
+ removedParagraphs,
66
+ replacementMatches,
67
+ };
68
+ }
69
+ export function sanitizeSystemText(text) {
70
+ return sanitizeSystemTextWithReport(text).text;
71
+ }
2
72
  function findProjectContextStart(systemPrompt) {
3
73
  const marker = "\n\n# Project Context\n\n";
4
74
  return systemPrompt.indexOf(marker);
@@ -6,14 +76,52 @@ function findProjectContextStart(systemPrompt) {
6
76
  /**
7
77
  * Shape a system prompt string for Anthropic OAuth compatibility.
8
78
  *
9
- * Replaces Pi's verbose default preamble with a minimal neutral prompt while
10
- * preserving any project context that follows. Returns the original string
11
- * unchanged when Pi's default preamble is not detected.
79
+ * For the normal upstream Pi prompt shape, sanitize only the known preamble
80
+ * span and replace its identity paragraph with the minimal neutral prompt.
81
+ * This preserves downstream configuration/extension points embedded in the
82
+ * preamble (tool snippets and guideline bullets) while still stripping the
83
+ * Pi-specific identity, filler, and documentation paragraphs.
84
+ *
85
+ * If Pi's known preamble terminator drifts upstream, we fall back to slicing
86
+ * from `# Project Context`. If that section is also absent, we return the
87
+ * minimal prompt only.
12
88
  */
13
89
  export function shapeAnthropicOAuthSystemPrompt(systemPrompt) {
14
- if (!systemPrompt.includes(PI_DEFAULT_PROMPT_PREFIX)) {
90
+ const prefixIdx = systemPrompt.indexOf(PI_DEFAULT_PROMPT_PREFIX);
91
+ if (prefixIdx === -1) {
15
92
  return systemPrompt;
16
93
  }
94
+ const terminatorIdx = systemPrompt.indexOf(PI_DEFAULT_PROMPT_TERMINATOR, prefixIdx);
95
+ if (terminatorIdx !== -1) {
96
+ const terminatorEnd = terminatorIdx + PI_DEFAULT_PROMPT_TERMINATOR.length;
97
+ const preamble = systemPrompt.slice(prefixIdx, terminatorEnd);
98
+ const report = sanitizeSystemTextWithReport(preamble);
99
+ const shapedPreamble = report.text
100
+ ? `${MINIMAL_ANTHROPIC_OAUTH_PROMPT}\n\n${report.text}`
101
+ : MINIMAL_ANTHROPIC_OAUTH_PROMPT;
102
+ if (shouldLogPromptDebug(report)) {
103
+ debugLog("system-prompt-shaping", {
104
+ mode: "terminator",
105
+ originalLength: systemPrompt.length,
106
+ preambleLength: preamble.length,
107
+ sanitizedPreambleLength: report.text.length,
108
+ removedParagraphCount: report.removedParagraphs.length,
109
+ removedAnchors: report.removedParagraphs.map((entry) => entry.anchor),
110
+ removedParagraphPreviews: report.removedParagraphs.map((entry) => entry.preview),
111
+ replacementMatches: report.replacementMatches,
112
+ });
113
+ }
114
+ return (systemPrompt.slice(0, prefixIdx) +
115
+ shapedPreamble +
116
+ systemPrompt.slice(terminatorEnd));
117
+ }
118
+ warnTerminatorMissingOnce();
119
+ if (!isToolUseOnlyDebugEnabled()) {
120
+ debugLog("system-prompt-shaping", {
121
+ mode: "project-context-fallback",
122
+ originalLength: systemPrompt.length,
123
+ });
124
+ }
17
125
  const projectContextStart = findProjectContextStart(systemPrompt);
18
126
  if (projectContextStart === -1) {
19
127
  return MINIMAL_ANTHROPIC_OAUTH_PROMPT;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-anthropic-auth",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Pi extension package for Anthropic OAuth compatibility",
5
5
  "author": {
6
6
  "name": "Chris Lasher"
@@ -38,7 +38,8 @@
38
38
  "lint:md": "markdownlint-cli2 '*.md'",
39
39
  "lint:all": "pnpm run lint && pnpm run lint:md",
40
40
  "format": "biome format --write .",
41
- "test": "tsx --test test/**/*.test.ts",
41
+ "test": "vitest run",
42
+ "test:watch": "vitest",
42
43
  "prepublishOnly": "pnpm run check && pnpm run test && pnpm run build"
43
44
  },
44
45
  "pi": {
@@ -56,7 +57,7 @@
56
57
  "@mariozechner/pi-ai": "^0.68.0",
57
58
  "@mariozechner/pi-coding-agent": "^0.68.0",
58
59
  "markdownlint-cli2": "^0.22.0",
59
- "tsx": "^4.20.6",
60
- "typescript": "^6.0.3"
60
+ "typescript": "^6.0.3",
61
+ "vitest": "^4.1.5"
61
62
  }
62
63
  }