@sahiljassal/opencode-anthropic-auth 2.6.0 → 2.7.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/README.md CHANGED
@@ -41,6 +41,8 @@ Additional behaviours:
41
41
  - **System tail coalescing** — plugin-added system blocks beyond the primary prompt are merged into one block before placing the system anchor, preventing cache busts when block layout changes between requests
42
42
  - **Trailing assistant strip** — assistant messages at the tail of the request are removed before forwarding (OAuth rejects assistant prefill)
43
43
  - **Tool pair repair** — `/compact` and `/undo` can leave a `tool_use` and its `tool_result` matched by id but no longer adjacent. Orphaned `tool_result` blocks are dropped; orphaned `tool_use` blocks are never deleted (Anthropic rejects edits to `thinking`/`redacted_thinking` blocks in the latest assistant message) — instead a placeholder `tool_result` is synthesized to restore adjacency
44
+ - **Assistant prefill strip** — trailing whitespace-only text after the latest assistant `tool_use` is removed before replay, so a valid tool-result continuation isn't rejected as an unsupported prefill
45
+ - **Server-side safety fallback** — OAuth requests for `claude-fable-5`/`claude-opus-5` opt into Anthropic's `fallbacks: "default"` so a content-filter refusal is transparently rerouted instead of returned as an error. Since OpenCode has no concept of the returned `fallback` content block, it's hidden behind a signed marker on the way out and restored on the next replay before the request is sent. Only affects the OAuth fetch path — API-key auth is untouched
44
46
  - **Thinking block guard** — `thinking` and `redacted_thinking` blocks are excluded from cache anchor placement; messages containing only thinking blocks receive no `cache_control` (avoids Anthropic 400)
45
47
  - **SSE retryable errors** — transient server errors (`api_error`, `overloaded_error`, `server_error`) emitted inside HTTP 200 streams are detected and thrown as connection-reset errors so OpenCode auto-retries
46
48
  - **Buffered stream rewriting** — tool name stripping buffers partial `"name"` tokens across chunk boundaries to avoid corruption
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { authorize, exchange } from "./auth.js";
2
2
  import { CLIENT_ID, OAUTH_REFRESH_SKEW_MS, TOKEN_URL } from "./constants.js";
3
+ import { isRecoverableRefusalModel } from "./server-fallback.js";
3
4
  import { computeRetryAfterDelayMs, createStrippedStream, extractModelId, isInsecure, mergeHeaders, rewriteRequestBody, rewriteUrl, setOAuthHeaders, } from "./transform.js";
4
5
  const MAX_429_RETRIES = 3;
5
6
  export const AnthropicAuthPlugin = async ({ client }) => {
@@ -121,12 +122,21 @@ export const AnthropicAuthPlugin = async ({ client }) => {
121
122
  const modelId = typeof rawBody === 'string'
122
123
  ? extractModelId(rawBody)
123
124
  : undefined;
124
- // biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
125
- setOAuthHeaders(requestHeaders, auth.access, modelId);
126
125
  let body = rawBody;
127
126
  if (body && typeof body === 'string') {
128
127
  body = rewriteRequestBody(body);
129
128
  }
129
+ let parsedBody;
130
+ if (typeof body === 'string') {
131
+ try {
132
+ parsedBody = JSON.parse(body);
133
+ }
134
+ catch {
135
+ parsedBody = undefined;
136
+ }
137
+ }
138
+ // biome-ignore lint/style/noNonNullAssertion: access is guaranteed set above
139
+ setOAuthHeaders(requestHeaders, auth.access, modelId, parsedBody);
130
140
  const rewritten = rewriteUrl(input);
131
141
  let accessToken = auth.access;
132
142
  let forcedRefreshAttempted = false;
@@ -150,7 +160,7 @@ export const AnthropicAuthPlugin = async ({ client }) => {
150
160
  if (refreshed === accessToken)
151
161
  break;
152
162
  accessToken = refreshed;
153
- setOAuthHeaders(requestHeaders, refreshed, modelId);
163
+ setOAuthHeaders(requestHeaders, refreshed, modelId, parsedBody);
154
164
  continue;
155
165
  }
156
166
  if (response.status !== 429 || attempt >= MAX_429_RETRIES) {
@@ -160,7 +170,10 @@ export const AnthropicAuthPlugin = async ({ client }) => {
160
170
  await response.body?.cancel();
161
171
  await new Promise((resolve) => setTimeout(resolve, delay));
162
172
  }
163
- return createStrippedStream(response);
173
+ const serverFallbackModel = isRecoverableRefusalModel(modelId)
174
+ ? modelId
175
+ : undefined;
176
+ return createStrippedStream(response, { serverFallbackModel });
164
177
  },
165
178
  };
166
179
  }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Anthropic's server-side safety fallback (`server-side-fallback-2026-07-01`)
3
+ * transparently reroutes a refused Fable 5/Opus 5 request to Opus 5/Opus 4.8
4
+ * instead of returning a content-filter refusal. Opting OAuth requests into
5
+ * it means the returned `fallback` content block records the handoff.
6
+ * OpenCode has no concept of that block type, so it can't be persisted or
7
+ * replayed as-is — this module hides it behind a signed `thinking` marker on
8
+ * the way out and restores the original block on the way back in.
9
+ */
10
+ export declare const SERVER_SIDE_FALLBACK_BETA = "server-side-fallback-2026-07-01";
11
+ export declare function isRecoverableRefusalModel(model: unknown): model is string;
12
+ /**
13
+ * Opts eligible Fable 5/Opus 5 requests into `fallbacks: "default"` and
14
+ * restores any hidden fallback-boundary markers left by a previous response.
15
+ */
16
+ export declare function applyServerSideFallbackToBody(body: Record<string, unknown>): {
17
+ enabled: boolean;
18
+ restoredMarkers: number;
19
+ droppedMarkers: number;
20
+ };
21
+ /**
22
+ * Streaming SSE rewriter: converts a returned `fallback` content block into
23
+ * a hidden signed `thinking` marker, so OpenCode can store and later replay
24
+ * a response that crossed a server-side fallback boundary.
25
+ */
26
+ export declare function createServerSideFallbackStreamRewriter(): {
27
+ push(text: string): string;
28
+ flush(): string;
29
+ };
@@ -0,0 +1,215 @@
1
+ /**
2
+ * Anthropic's server-side safety fallback (`server-side-fallback-2026-07-01`)
3
+ * transparently reroutes a refused Fable 5/Opus 5 request to Opus 5/Opus 4.8
4
+ * instead of returning a content-filter refusal. Opting OAuth requests into
5
+ * it means the returned `fallback` content block records the handoff.
6
+ * OpenCode has no concept of that block type, so it can't be persisted or
7
+ * replayed as-is — this module hides it behind a signed `thinking` marker on
8
+ * the way out and restores the original block on the way back in.
9
+ */
10
+ export const SERVER_SIDE_FALLBACK_BETA = 'server-side-fallback-2026-07-01';
11
+ const MARKER_TEXT = '\u2060';
12
+ const SIGNATURE_PREFIX = 'opencode-anthropic-auth-server-fallback-v1:';
13
+ const RECOVERABLE_REFUSAL_MODEL_PATTERN = /^claude-(fable-5|opus-5)(-.*)?$/i;
14
+ function isRecord(value) {
15
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
16
+ }
17
+ function stringField(value, key) {
18
+ const field = value?.[key];
19
+ return typeof field === 'string' ? field : undefined;
20
+ }
21
+ export function isRecoverableRefusalModel(model) {
22
+ return (typeof model === 'string' && RECOVERABLE_REFUSAL_MODEL_PATTERN.test(model));
23
+ }
24
+ function encodeMarkerSignature(marker) {
25
+ return `${SIGNATURE_PREFIX}${marker.fromModel}|${marker.toModel}`;
26
+ }
27
+ function decodeMarkerSignature(value) {
28
+ if (typeof value !== 'string' || !value.startsWith(SIGNATURE_PREFIX)) {
29
+ return null;
30
+ }
31
+ const encoded = value.slice(SIGNATURE_PREFIX.length);
32
+ const separator = encoded.indexOf('|');
33
+ if (separator <= 0 || separator !== encoded.lastIndexOf('|'))
34
+ return null;
35
+ const fromModel = encoded.slice(0, separator);
36
+ const toModel = encoded.slice(separator + 1);
37
+ if (!fromModel || !toModel)
38
+ return null;
39
+ return { fromModel, toModel };
40
+ }
41
+ function markerFromFallbackBlock(block) {
42
+ if (block.type !== 'fallback')
43
+ return null;
44
+ const fromModel = stringField(isRecord(block.from) ? block.from : undefined, 'model');
45
+ const toModel = stringField(isRecord(block.to) ? block.to : undefined, 'model');
46
+ if (!fromModel || !toModel)
47
+ return null;
48
+ return { fromModel, toModel };
49
+ }
50
+ function fallbackBlock(marker) {
51
+ return {
52
+ type: 'fallback',
53
+ from: { model: marker.fromModel },
54
+ to: { model: marker.toModel },
55
+ };
56
+ }
57
+ function rewriteStoredMarkers(body, enabled) {
58
+ let restoredMarkers = 0;
59
+ let droppedMarkers = 0;
60
+ if (!Array.isArray(body.messages))
61
+ return { restoredMarkers, droppedMarkers };
62
+ for (const rawMessage of body.messages) {
63
+ if (!isRecord(rawMessage) ||
64
+ rawMessage.role !== 'assistant' ||
65
+ !Array.isArray(rawMessage.content)) {
66
+ continue;
67
+ }
68
+ const content = rawMessage.content;
69
+ const rewritten = [];
70
+ let messageRestored = 0;
71
+ let messageDropped = 0;
72
+ for (const rawBlock of content) {
73
+ const block = isRecord(rawBlock) ? rawBlock : undefined;
74
+ const isMarker = block?.type === 'thinking' &&
75
+ block.thinking === MARKER_TEXT &&
76
+ typeof block.signature === 'string' &&
77
+ block.signature.startsWith(SIGNATURE_PREFIX);
78
+ if (!isMarker) {
79
+ rewritten.push(rawBlock);
80
+ continue;
81
+ }
82
+ const marker = decodeMarkerSignature(block.signature);
83
+ if (enabled && marker) {
84
+ messageRestored++;
85
+ rewritten.push(fallbackBlock(marker));
86
+ continue;
87
+ }
88
+ messageDropped++;
89
+ }
90
+ // Dropping every block would leave an empty content array, which
91
+ // Anthropic rejects — leave the message untouched instead.
92
+ if (rewritten.length === 0 && content.length > 0)
93
+ continue;
94
+ restoredMarkers += messageRestored;
95
+ droppedMarkers += messageDropped;
96
+ rawMessage.content = rewritten;
97
+ }
98
+ return { restoredMarkers, droppedMarkers };
99
+ }
100
+ /**
101
+ * Opts eligible Fable 5/Opus 5 requests into `fallbacks: "default"` and
102
+ * restores any hidden fallback-boundary markers left by a previous response.
103
+ */
104
+ export function applyServerSideFallbackToBody(body) {
105
+ const enabled = isRecoverableRefusalModel(body.model);
106
+ const markerResult = rewriteStoredMarkers(body, enabled);
107
+ if (enabled) {
108
+ body.fallbacks = 'default';
109
+ }
110
+ else if (body.fallbacks === 'default') {
111
+ delete body.fallbacks;
112
+ }
113
+ return { enabled, ...markerResult };
114
+ }
115
+ function findSseBoundary(value) {
116
+ const lf = value.indexOf('\n\n');
117
+ const crlf = value.indexOf('\r\n\r\n');
118
+ if (lf === -1)
119
+ return crlf === -1 ? null : { index: crlf, length: 4 };
120
+ if (crlf === -1 || lf < crlf)
121
+ return { index: lf, length: 2 };
122
+ return { index: crlf, length: 4 };
123
+ }
124
+ function parseSseEvent(rawEvent) {
125
+ let event;
126
+ const dataLines = [];
127
+ for (const line of rawEvent.split(/\r?\n/)) {
128
+ if (line.startsWith('event:')) {
129
+ event = line.slice('event:'.length).trim();
130
+ }
131
+ else if (line.startsWith('data:')) {
132
+ const value = line.slice('data:'.length);
133
+ dataLines.push(value.startsWith(' ') ? value.slice(1) : value);
134
+ }
135
+ }
136
+ const text = dataLines.join('\n');
137
+ if (!text || text === '[DONE]')
138
+ return { event };
139
+ try {
140
+ const parsed = JSON.parse(text);
141
+ return { event, data: isRecord(parsed) ? parsed : undefined };
142
+ }
143
+ catch {
144
+ return { event };
145
+ }
146
+ }
147
+ function sseFrame(event, data) {
148
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
149
+ }
150
+ function hiddenMarkerFrames(index, marker) {
151
+ return (sseFrame('content_block_start', {
152
+ type: 'content_block_start',
153
+ index,
154
+ content_block: { type: 'thinking', thinking: MARKER_TEXT },
155
+ }) +
156
+ sseFrame('content_block_delta', {
157
+ type: 'content_block_delta',
158
+ index,
159
+ delta: {
160
+ type: 'signature_delta',
161
+ signature: encodeMarkerSignature(marker),
162
+ },
163
+ }));
164
+ }
165
+ /**
166
+ * Streaming SSE rewriter: converts a returned `fallback` content block into
167
+ * a hidden signed `thinking` marker, so OpenCode can store and later replay
168
+ * a response that crossed a server-side fallback boundary.
169
+ */
170
+ export function createServerSideFallbackStreamRewriter() {
171
+ let pending = '';
172
+ const rewriteEvent = (rawEvent, boundary) => {
173
+ const parsed = parseSseEvent(rawEvent);
174
+ const data = parsed.data;
175
+ const type = stringField(data, 'type') ?? parsed.event;
176
+ if (type === 'content_block_start') {
177
+ const block = isRecord(data?.content_block)
178
+ ? data.content_block
179
+ : undefined;
180
+ const marker = block ? markerFromFallbackBlock(block) : null;
181
+ if (marker) {
182
+ const index = typeof data?.index === 'number' ? data.index : 0;
183
+ return hiddenMarkerFrames(index, marker);
184
+ }
185
+ }
186
+ return rawEvent + boundary;
187
+ };
188
+ const drain = () => {
189
+ let output = '';
190
+ while (true) {
191
+ const boundary = findSseBoundary(pending);
192
+ if (!boundary)
193
+ break;
194
+ const rawEvent = pending.slice(0, boundary.index);
195
+ const delimiter = pending.slice(boundary.index, boundary.index + boundary.length);
196
+ pending = pending.slice(boundary.index + boundary.length);
197
+ output += rewriteEvent(rawEvent, delimiter);
198
+ }
199
+ return output;
200
+ };
201
+ return {
202
+ push(text) {
203
+ pending += text;
204
+ return drain();
205
+ },
206
+ flush() {
207
+ const output = drain();
208
+ if (!pending)
209
+ return output;
210
+ const tail = rewriteEvent(pending, '');
211
+ pending = '';
212
+ return output + tail;
213
+ },
214
+ };
215
+ }
@@ -14,7 +14,7 @@ export declare function mergeBetaHeaders(headers: Headers, modelId?: string): st
14
14
  * Set OAuth-required headers: authorization, beta, user-agent.
15
15
  * Removes x-api-key since we're using OAuth.
16
16
  */
17
- export declare function setOAuthHeaders(headers: Headers, accessToken: string, modelId?: string): Headers;
17
+ export declare function setOAuthHeaders(headers: Headers, accessToken: string, modelId?: string, body?: unknown): Headers;
18
18
  /**
19
19
  * Extract the `model` field from a JSON request body string, if present.
20
20
  * Used to make header rewriting (e.g. beta exclusions) model-aware before
@@ -92,5 +92,7 @@ export type RetryableAnthropicStreamError = Error & {
92
92
  * Detects retryable Anthropic server errors inside HTTP 200 streams and
93
93
  * throws a connection-reset-style error so OpenCode can auto-retry.
94
94
  */
95
- export declare function createStrippedStream(response: Response): Response;
95
+ export declare function createStrippedStream(response: Response, options?: {
96
+ serverFallbackModel?: string;
97
+ }): Response;
96
98
  export {};
package/dist/transform.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ADAPTIVE_THINKING_MODEL_PATTERN, ANTHROPIC_CACHE_LOOKBACK_BLOCKS, CLAUDE_CODE_IDENTITY, OPENCODE_IDENTITY_PREFIX, PARAGRAPH_REMOVAL_ANCHORS, REQUIRED_BETAS, TEXT_REPLACEMENTS, TOOL_PREFIX, TOOL_RESULT_PLACEHOLDER, USER_AGENT, } from "./constants.js";
2
+ import { applyServerSideFallbackToBody, createServerSideFallbackStreamRewriter, SERVER_SIDE_FALLBACK_BETA, } from "./server-fallback.js";
2
3
  function prefixName(name) {
3
4
  return `${TOOL_PREFIX}${name.charAt(0).toUpperCase()}${name.slice(1)}`;
4
5
  }
@@ -71,9 +72,18 @@ export function mergeBetaHeaders(headers, modelId) {
71
72
  * Set OAuth-required headers: authorization, beta, user-agent.
72
73
  * Removes x-api-key since we're using OAuth.
73
74
  */
74
- export function setOAuthHeaders(headers, accessToken, modelId) {
75
+ export function setOAuthHeaders(headers, accessToken, modelId, body) {
75
76
  headers.set('authorization', `Bearer ${accessToken}`);
76
- headers.set('anthropic-beta', mergeBetaHeaders(headers, modelId));
77
+ let beta = mergeBetaHeaders(headers, modelId);
78
+ if (isRecord(body) && body.fallbacks === 'default') {
79
+ beta = [
80
+ ...new Set([
81
+ ...beta.split(',').filter(Boolean),
82
+ SERVER_SIDE_FALLBACK_BETA,
83
+ ]),
84
+ ].join(',');
85
+ }
86
+ headers.set('anthropic-beta', beta);
77
87
  headers.set('user-agent', USER_AGENT);
78
88
  headers.delete('x-api-key');
79
89
  return headers;
@@ -630,6 +640,37 @@ function stripTrailingAssistantMessages(parsed) {
630
640
  parsed.messages.pop();
631
641
  }
632
642
  }
643
+ /**
644
+ * Anthropic can classify whitespace-only text after the latest assistant
645
+ * tool_use as assistant prefill on a later tool-result continuation, even
646
+ * though the request ends with a user turn. Strip it; preserve meaningful
647
+ * text and all earlier turns.
648
+ */
649
+ function stripLatestAssistantToolUseTrailingWhitespace(parsed) {
650
+ if (!Array.isArray(parsed.messages))
651
+ return;
652
+ for (let index = parsed.messages.length - 1; index >= 0; index--) {
653
+ const message = parsed.messages[index];
654
+ if (!isRecord(message) || message.role !== 'assistant')
655
+ continue;
656
+ if (!Array.isArray(message.content))
657
+ return;
658
+ const hasToolUse = message.content.some((block) => isRecord(block) && block.type === 'tool_use');
659
+ if (!hasToolUse)
660
+ return;
661
+ while (message.content.length) {
662
+ const block = message.content[message.content.length - 1];
663
+ if (!isRecord(block) ||
664
+ block.type !== 'text' ||
665
+ typeof block.text !== 'string' ||
666
+ block.text.trim()) {
667
+ break;
668
+ }
669
+ message.content.pop();
670
+ }
671
+ return;
672
+ }
673
+ }
633
674
  /**
634
675
  * Returns true when messages[0] carries a merged stable-prefix layout
635
676
  * (≥2 cacheable blocks). In that case anchoring the last block would bust
@@ -740,6 +781,8 @@ export function rewriteRequestBody(body) {
740
781
  stripRestrictedSamplingParams(parsed);
741
782
  stripUnsupportedEffortForHaiku(parsed);
742
783
  stripTrailingAssistantMessages(parsed);
784
+ stripLatestAssistantToolUseTrailingWhitespace(parsed);
785
+ applyServerSideFallbackToBody(parsed);
743
786
  applyHybridCache1h(parsed);
744
787
  return prefixToolNames(parsed);
745
788
  }
@@ -877,7 +920,7 @@ function splitToolPrefixRewriteBuffer(buffer, flush = false) {
877
920
  * Detects retryable Anthropic server errors inside HTTP 200 streams and
878
921
  * throws a connection-reset-style error so OpenCode can auto-retry.
879
922
  */
880
- export function createStrippedStream(response) {
923
+ export function createStrippedStream(response, options) {
881
924
  if (!response.body)
882
925
  return response;
883
926
  const reader = response.body.getReader();
@@ -886,6 +929,9 @@ export function createStrippedStream(response) {
886
929
  let pending = '';
887
930
  let readerReleased = false;
888
931
  const sseErrors = createSseErrorState();
932
+ const fallbackRewriter = options?.serverFallbackModel
933
+ ? createServerSideFallbackStreamRewriter()
934
+ : undefined;
889
935
  const releaseReader = () => {
890
936
  if (readerReleased)
891
937
  return;
@@ -897,7 +943,9 @@ export function createStrippedStream(response) {
897
943
  try {
898
944
  const { done, value } = await reader.read();
899
945
  if (done) {
900
- const finalDecoded = decoder.decode();
946
+ const finalDecoded = fallbackRewriter
947
+ ? fallbackRewriter.push(decoder.decode()) + fallbackRewriter.flush()
948
+ : decoder.decode();
901
949
  let retryableError = updateSseErrorState(sseErrors, finalDecoded);
902
950
  if (!retryableError && sseErrors.pending) {
903
951
  retryableError = retryableAnthropicStreamErrorFromRawEvent(sseErrors.pending);
@@ -919,7 +967,9 @@ export function createStrippedStream(response) {
919
967
  controller.close();
920
968
  return;
921
969
  }
922
- const decoded = decoder.decode(value, { stream: true });
970
+ const decoded = fallbackRewriter
971
+ ? fallbackRewriter.push(decoder.decode(value, { stream: true }))
972
+ : decoder.decode(value, { stream: true });
923
973
  const retryableError = updateSseErrorState(sseErrors, decoded);
924
974
  if (retryableError) {
925
975
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sahiljassal/opencode-anthropic-auth",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/shljsl75891/opencode-anthropic-auth.git"