@vanillagreen/pi-claude-bridge 1.1.4 → 1.3.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@vanillagreen/pi-claude-bridge",
3
- "version": "1.1.4",
3
+ "version": "1.3.0",
4
4
  "description": "Pi provider bridge that runs Claude Code through the Claude Agent SDK, with opt-in forwarding for Pi prompt context.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -85,6 +85,50 @@
85
85
  "category": "Claude Code",
86
86
  "apply": "live"
87
87
  },
88
+ {
89
+ "key": "allowExtraUsage",
90
+ "label": "Allow extra usage helper",
91
+ "description": "Allow claude-bridge to launch Claude Code's /extra-usage flow when rate limits require extra usage. Billing/admin approval still happens in Claude's browser page.",
92
+ "type": "boolean",
93
+ "default": false,
94
+ "category": "Claude Code",
95
+ "apply": "live"
96
+ },
97
+ {
98
+ "key": "fastMode",
99
+ "label": "Fast mode",
100
+ "description": "Enable Claude Code fast mode for bridge requests when the selected model supports it. Off by default.",
101
+ "type": "boolean",
102
+ "default": false,
103
+ "category": "Claude Code",
104
+ "apply": "live"
105
+ },
106
+ {
107
+ "key": "forceEffort",
108
+ "label": "Force Claude effort",
109
+ "description": "Override Pi's thinking-level mapping for all claude-bridge requests. Use max to reach Claude Code max effort even though Pi has no max thinking level. none keeps Pi's selected level.",
110
+ "type": "enum",
111
+ "enumValues": [
112
+ "none",
113
+ "low",
114
+ "medium",
115
+ "high",
116
+ "xhigh",
117
+ "max"
118
+ ],
119
+ "default": "none",
120
+ "category": "Claude Code",
121
+ "apply": "live"
122
+ },
123
+ {
124
+ "key": "modelEffortOverrides",
125
+ "label": "Model effort overrides",
126
+ "description": "Optional JSON object mapping claude-bridge model ids to Claude Code effort levels, e.g. {\"claude-opus-4-8\":\"max\"}. Keys may also use claude-bridge/<id> or *.",
127
+ "type": "string",
128
+ "default": "{}",
129
+ "category": "Claude Code",
130
+ "apply": "live"
131
+ },
88
132
  {
89
133
  "key": "pathToClaudeCodeExecutable",
90
134
  "label": "Claude executable path",
@@ -98,8 +142,8 @@
98
142
  }
99
143
  },
100
144
  "dependencies": {
101
- "@anthropic-ai/claude-agent-sdk": "0.2.128",
102
- "@anthropic-ai/sdk": "^0.73.0",
145
+ "@anthropic-ai/claude-agent-sdk": "0.3.158",
146
+ "@anthropic-ai/sdk": "0.93.0",
103
147
  "cc-session-io": "^0.3.1",
104
148
  "change-case": "^5.4.4"
105
149
  },
package/src/config.ts CHANGED
@@ -9,11 +9,22 @@ import { dirname, join, resolve } from "path";
9
9
 
10
10
  export const PACKAGE_ID = "@vanillagreen/pi-claude-bridge";
11
11
 
12
+ export type BridgeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max";
13
+
14
+ const VALID_EFFORT_LEVELS = new Set<BridgeEffortLevel>(["low", "medium", "high", "xhigh", "max"]);
15
+
12
16
  export interface Config {
13
17
  enabled?: boolean;
14
18
  /** Low-level Claude Agent SDK plumbing. Most users won't need these. */
15
19
  provider?: {
16
20
  appendSystemPrompt?: boolean;
21
+ allowExtraUsage?: boolean;
22
+ /** Enable Claude Code fast mode for bridge requests. */
23
+ fastMode?: boolean;
24
+ /** Force this Claude Code effort level for every bridge request. */
25
+ forceEffort?: BridgeEffortLevel;
26
+ /** Per-model Claude Code effort overrides keyed by model id (e.g. claude-opus-4-8). */
27
+ modelEffortOverrides?: Record<string, BridgeEffortLevel>;
17
28
  settingSources?: SettingSource[];
18
29
  strictMcpConfig?: boolean;
19
30
  pathToClaudeCodeExecutable?: string;
@@ -105,12 +116,69 @@ function stringFrom(raw: SettingsRecord, key: string): string | undefined {
105
116
  return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
106
117
  }
107
118
 
119
+ function hasOwn(raw: SettingsRecord, key: string): boolean {
120
+ return Object.prototype.hasOwnProperty.call(raw, key);
121
+ }
122
+
123
+ export function normalizeEffortLevel(value: unknown): BridgeEffortLevel | undefined {
124
+ if (typeof value !== "string") return undefined;
125
+ const normalized = value.trim().toLowerCase();
126
+ if (normalized === "" || normalized === "none" || normalized === "auto" || normalized === "default") return undefined;
127
+ return VALID_EFFORT_LEVELS.has(normalized as BridgeEffortLevel) ? normalized as BridgeEffortLevel : undefined;
128
+ }
129
+
130
+ export function normalizeModelEffortOverrides(value: unknown): Record<string, BridgeEffortLevel> | undefined {
131
+ let source: unknown = value;
132
+ if (typeof source === "string") {
133
+ const trimmed = source.trim();
134
+ if (!trimmed || trimmed === "{}") return undefined;
135
+ try {
136
+ source = JSON.parse(trimmed);
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+ const record = asRecord(source);
142
+ if (!record) return undefined;
143
+
144
+ const out: Record<string, BridgeEffortLevel> = {};
145
+ for (const [modelId, rawEffort] of Object.entries(record)) {
146
+ const key = modelId.trim();
147
+ const effort = normalizeEffortLevel(rawEffort);
148
+ if (key && effort) out[key] = effort;
149
+ }
150
+ return Object.keys(out).length > 0 ? out : undefined;
151
+ }
152
+
153
+ function normalizeProviderConfig(provider: Config["provider"] | undefined): Config["provider"] {
154
+ if (!provider) return {};
155
+ const raw = provider as SettingsRecord;
156
+ const out: Config["provider"] = { ...provider };
157
+ const forceEffort = normalizeEffortLevel(raw.forceEffort);
158
+ if (forceEffort) out.forceEffort = forceEffort;
159
+ else delete out.forceEffort;
160
+ const modelEffortOverrides = normalizeModelEffortOverrides(raw.modelEffortOverrides);
161
+ if (modelEffortOverrides) out.modelEffortOverrides = modelEffortOverrides;
162
+ else delete out.modelEffortOverrides;
163
+ return out;
164
+ }
165
+
108
166
  function managerToConfig(raw: SettingsRecord): Partial<Config> {
109
167
  const provider: Config["provider"] = {};
110
168
  const promptContext: Config["promptContext"] = {};
111
169
 
112
170
  const appendSystemPrompt = boolFrom(raw, "appendSystemPrompt");
113
171
  if (appendSystemPrompt !== undefined) provider.appendSystemPrompt = appendSystemPrompt;
172
+ const allowExtraUsage = boolFrom(raw, "allowExtraUsage");
173
+ if (allowExtraUsage !== undefined) provider.allowExtraUsage = allowExtraUsage;
174
+ const fastMode = boolFrom(raw, "fastMode");
175
+ if (fastMode !== undefined) provider.fastMode = fastMode;
176
+ if (hasOwn(raw, "forceEffort")) {
177
+ provider.forceEffort = normalizeEffortLevel(raw.forceEffort);
178
+ }
179
+ if (hasOwn(raw, "modelEffortOverrides")) {
180
+ provider.modelEffortOverrides = normalizeModelEffortOverrides(raw.modelEffortOverrides);
181
+ }
114
182
  const strictMcpConfig = boolFrom(raw, "strictMcpConfig");
115
183
  if (strictMcpConfig !== undefined) provider.strictMcpConfig = strictMcpConfig;
116
184
  const claudePath = stringFrom(raw, "pathToClaudeCodeExecutable");
@@ -136,9 +204,10 @@ export function loadConfig(cwd: string): Config {
136
204
  const global = tryParseJson(join(piUserDir(), "claude-bridge.json"));
137
205
  const project = tryParseJson(join(cwd, ".pi", "claude-bridge.json"));
138
206
  const manager = managerToConfig(readManagerConfig(cwd));
207
+ const provider = normalizeProviderConfig({ ...global.provider, ...project.provider, ...manager.provider });
139
208
  return {
140
209
  enabled: manager.enabled ?? project.enabled ?? global.enabled ?? true,
141
- provider: { ...global.provider, ...project.provider, ...manager.provider },
210
+ provider,
142
211
  promptContext: { ...global.promptContext, ...project.promptContext, ...manager.promptContext },
143
212
  };
144
213
  }
package/src/convert.ts CHANGED
@@ -80,6 +80,34 @@ function assistantProvenancePrefix(msg: PiMessage): string | undefined {
80
80
  return `[Prior Pi assistant response from ${provider ?? api ?? "unknown-provider"}${model ? `/${model}` : ""}]\n`;
81
81
  }
82
82
 
83
+ function userMessageToAnthropic(msg: PiMessage): SessionMessage {
84
+ if (typeof msg.content === "string") return { role: "user", content: msg.content || "[empty]" };
85
+ if (Array.isArray(msg.content)) {
86
+ const parts = [];
87
+ for (const block of msg.content) {
88
+ if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
89
+ else if (block.type === "image" && block.data && block.mimeType) parts.push(imageBlockToAnthropic(block));
90
+ }
91
+ const kept = parts.filter(Boolean) as ContentBlock[];
92
+ return { role: "user", content: kept.length ? kept : "[image]" };
93
+ }
94
+ return { role: "user", content: "[empty]" };
95
+ }
96
+
97
+ function toolResultToAnthropicBlock(msg: PiMessage, sanitizedIds: Map<string, string>): ContentBlock {
98
+ const content = toolResultContentToAnthropic(msg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>);
99
+ return {
100
+ type: "tool_result",
101
+ tool_use_id: sanitizeToolId((msg as { toolCallId: string }).toolCallId, sanitizedIds),
102
+ content: content || "",
103
+ is_error: (msg as { isError?: boolean }).isError,
104
+ } as ContentBlock;
105
+ }
106
+
107
+ function hasToolUse(msg: PiMessage): boolean {
108
+ return msg.role === "assistant" && Array.isArray(msg.content) && msg.content.some((block) => block.type === "toolCall");
109
+ }
110
+
83
111
  /** Convert pi message array to Anthropic API format. */
84
112
  export function convertPiMessages(
85
113
  messages: PiMessage[],
@@ -88,22 +116,26 @@ export function convertPiMessages(
88
116
  const anthropicMessages = [];
89
117
  const sanitizedIds = new Map();
90
118
 
91
- for (const msg of messages) {
119
+ const pushToolResultGroup = (toolMessages: PiMessage[]): void => {
120
+ if (toolMessages.length === 0) return;
121
+ anthropicMessages.push({
122
+ role: "user",
123
+ content: toolMessages.map((toolMsg) => {
124
+ const content = toolResultContentToAnthropic(toolMsg.content as string | Array<{ type: string; text?: string; data?: string; mimeType?: string }>);
125
+ return {
126
+ type: "tool_result",
127
+ tool_use_id: sanitizeToolId((toolMsg as { toolCallId: string }).toolCallId, sanitizedIds),
128
+ content: content || "",
129
+ is_error: (toolMsg as { isError?: boolean }).isError,
130
+ };
131
+ }),
132
+ });
133
+ };
134
+
135
+ for (let i = 0; i < messages.length; i++) {
136
+ const msg = messages[i];
92
137
  if (msg.role === "user") {
93
- if (typeof msg.content === "string") {
94
- anthropicMessages.push({ role: "user", content: msg.content || "[empty]" });
95
- } else if (Array.isArray(msg.content)) {
96
- const parts = [];
97
- for (const block of msg.content) {
98
- if (block.type === "text" && block.text) parts.push({ type: "text", text: block.text });
99
- else if (block.type === "image" && block.data && block.mimeType) {
100
- parts.push(imageBlockToAnthropic(block));
101
- }
102
- }
103
- anthropicMessages.push({ role: "user", content: parts.filter(Boolean).length ? parts.filter(Boolean) as ContentBlock[] : "[image]" });
104
- } else {
105
- anthropicMessages.push({ role: "user", content: "[empty]" });
106
- }
138
+ anthropicMessages.push(userMessageToAnthropic(msg));
107
139
  } else if (msg.role === "assistant") {
108
140
  const content = Array.isArray(msg.content) ? msg.content : [];
109
141
  const blocks = [];
@@ -125,12 +157,37 @@ export function convertPiMessages(
125
157
  }
126
158
  if (!blocks.length) blocks.push({ type: "text", text: "[incompatible content omitted]" });
127
159
  anthropicMessages.push({ role: "assistant", content: blocks });
160
+
161
+ // Pi may inject steer/followUp user messages between parallel tool
162
+ // results, while runtime extraction treats every toolResult after the
163
+ // assistant (until the next assistant) as one turn. Claude history must
164
+ // put all tool_result blocks immediately after the tool_use assistant;
165
+ // replay interleaved user text only after that grouped result message.
166
+ if (hasToolUse(msg)) {
167
+ const toolMessages: PiMessage[] = [];
168
+ const interleavedUsers: PiMessage[] = [];
169
+ let j = i + 1;
170
+ for (; j < messages.length; j++) {
171
+ const next = messages[j];
172
+ if (next.role === "assistant") break;
173
+ if (next.role === "toolResult") toolMessages.push(next);
174
+ else if (next.role === "user") interleavedUsers.push(next);
175
+ else break;
176
+ }
177
+ if (toolMessages.length > 0) {
178
+ pushToolResultGroup(toolMessages);
179
+ for (const userMsg of interleavedUsers) anthropicMessages.push(userMessageToAnthropic(userMsg));
180
+ i = j - 1;
181
+ }
182
+ }
128
183
  } else if (msg.role === "toolResult") {
129
- const content = toolResultContentToAnthropic(msg.content);
130
- anthropicMessages.push({
131
- role: "user",
132
- content: [{ type: "tool_result", tool_use_id: sanitizeToolId(msg.toolCallId, sanitizedIds), content: content || "", is_error: msg.isError }],
133
- });
184
+ const blocks: ContentBlock[] = [];
185
+ for (; i < messages.length; i++) {
186
+ const toolMsg = messages[i];
187
+ if (toolMsg.role !== "toolResult") { i--; break; }
188
+ blocks.push(toolResultToAnthropicBlock(toolMsg, sanitizedIds));
189
+ }
190
+ anthropicMessages.push({ role: "user", content: blocks });
134
191
  }
135
192
  }
136
193