Skip to content

Conversation

@deveworld
Copy link

@deveworld deveworld commented Jan 9, 2026

Summary

  • Normalize Anthropic OAuth requests to match Claude Code's headers, betas, metadata, tool casing, and model IDs.
  • Remove tool_choice and inject metadata.user_id from ~/.claude.json to satisfy OAuth validation.
  • Rewrite streaming SSE responses so models/tools appear in OpenCode's expected format (UI renders correctly).

Why

Anthropic now validates Claude Code–specific request shape for OAuth tokens. Matching only tool prefixes is insufficient; headers, betas, metadata, tool casing, and model IDs must also align to avoid 400s and missing UI output.

Test Plan

  • Clone and wire the plugin locally:
    • git clone https://github.com/deveworld/opencode-anthropic-auth
    • Add to ~/.config/opencode/opencode.json: "plugin": ["file:///path/to/opencode-anthropic-auth/index.mjs"]
    • Run: OPENCODE_DISABLE_DEFAULT_PLUGINS=true opencode
  • In OpenCode, call /v1/messages and verify streaming renders in the UI.
  • Confirm OAuth requests no longer return 400 errors.

Copilot AI review requested due to automatic review settings January 9, 2026 18:02
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aligns Anthropic OAuth requests with Claude Code's expected format to satisfy OAuth validation requirements. The changes normalize headers, beta flags, metadata, tool naming conventions, and model IDs to match what Anthropic's OAuth validation expects.

Key changes:

  • Add global fetch patching to intercept and transform all Anthropic API requests
  • Normalize tool names between OpenCode's snake_case and Claude Code's PascalCase formats
  • Inject Claude Code-specific headers (x-stainless-*, x-app, user-agent) and metadata.user_id
  • Transform streaming responses to reverse tool/model name changes for UI compatibility

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 443 to 505
if (cachedMetadataUserIdPromise) return cachedMetadataUserIdPromise;

cachedMetadataUserIdPromise = (async () => {
const home = env.HOME ?? env.USERPROFILE;
if (!home) return undefined;
const configPath = env.OPENCODE_CLAUDE_CONFIG ?? `${home}/.claude.json`;
try {
const { readFile } = await import("node:fs/promises");
const raw = await readFile(configPath, "utf8");
const data = JSON.parse(raw);
const userId = data?.userID;
const accountUuid = data?.oauthAccount?.accountUuid;
let sessionId = undefined;
const cwd = globalThis.process?.cwd?.();
if (cwd && data?.projects?.[cwd]?.lastSessionId) {
sessionId = data.projects[cwd].lastSessionId;
} else if (data?.projects && typeof data.projects === "object") {
for (const value of Object.values(data.projects)) {
if (value && typeof value === "object" && value.lastSessionId) {
sessionId = value.lastSessionId;
break;
}
}
}

if (userId && accountUuid && sessionId) {
return `user_${userId}_account_${accountUuid}_session_${sessionId}`;
}
} catch {
return undefined;
}
return undefined;
})();

return cachedMetadataUserIdPromise;
}
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a race condition in how cachedMetadataUserIdPromise is used. If resolveMetadataUserId() is called multiple times before the file read completes, the first call creates the promise, but if the file read fails (caught in the try-catch), subsequent calls will return the same cached promise that resolved to undefined. This means a transient error (like temporary permission issues) will permanently cache undefined for the lifetime of the process. Consider resetting cachedMetadataUserIdPromise to null in the catch block if the read fails, allowing retry on subsequent calls.

Copilot uses AI. Check for mistakes.
index.mjs Outdated
Comment on lines 357 to 390
if (metadataUserId) {
if (!parsed.metadata || typeof parsed.metadata !== "object") {
parsed.metadata = {};
}
if (!parsed.metadata.user_id) {
parsed.metadata.user_id = metadataUserId;
}
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function unconditionally injects metadata.user_id on line 362 even if metadataUserId is undefined or falsy. The check if (metadataUserId) on line 357 guards the creation of the metadata object, but if parsed.metadata already exists, line 362 will set user_id to undefined when metadataUserId is falsy. This differs from the similar code at lines 785-787 which correctly checks if (metadataUserId && !parsed.metadata.user_id). The guard should be moved to wrap line 362 as well.

Suggested change
if (metadataUserId) {
if (!parsed.metadata || typeof parsed.metadata !== "object") {
parsed.metadata = {};
}
if (!parsed.metadata.user_id) {
parsed.metadata.user_id = metadataUserId;
}
if (metadataUserId && (!parsed.metadata || !parsed.metadata.user_id)) {
if (!parsed.metadata || typeof parsed.metadata !== "object") {
parsed.metadata = {};
}
parsed.metadata.user_id = metadataUserId;

Copilot uses AI. Check for mistakes.
}
if (options.tool_choice) {
delete options.tool_choice;
}
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chat.params hook modifies the outgoing request but doesn't transform the incoming streaming responses to reverse the tool name and model ID transformations. This means responses from the chat.params path will have Claude Code format names (PascalCase) but the UI expects OpenCode format (snake_case). The other code paths (installAnthropicFetchPatch and loader.fetch) handle this via replaceToolNamesInText, but this hook has no equivalent response transformation. Either streaming responses need to be handled elsewhere for this path, or response transformation should be added here.

Suggested change
}
}
// Ensure streamed responses have tool names/model IDs converted back
// from Claude Code format to the OpenCode format expected by the UI.
if (output) {
if (typeof output.transformStream === "function") {
const originalTransformStream = output.transformStream;
output.transformStream = async function* (stream) {
for await (let chunk of originalTransformStream.call(this, stream)) {
if (typeof chunk === "string") {
chunk = replaceToolNamesInText(chunk);
}
yield chunk;
}
};
} else if (typeof output.transform === "function") {
const originalTransform = output.transform;
output.transform = async function* (stream) {
for await (let chunk of originalTransform.call(this, stream)) {
if (typeof chunk === "string") {
chunk = replaceToolNamesInText(chunk);
}
yield chunk;
}
};
}
}

Copilot uses AI. Check for mistakes.
Comment on lines 84 to 130
function replaceToolNamesInText(text) {
let output = text.replace(/"name"\s*:\s*"oc_([^"]+)"/g, '"name": "$1"');
output = output.replace(
/"name"\s*:\s*"(Bash|Read|Edit|Write|Task|Glob|Grep|WebFetch|WebSearch|TodoWrite)"/g,
(match, name) => `"name": "${normalizeToolNameForOpenCode(name)}"`,
);
for (const [pascal, original] of TOOL_NAME_CACHE.entries()) {
if (!pascal || pascal === original) continue;
const pattern = new RegExp(
`"name"\\s*:\\s*"${escapeRegExp(pascal)}"`,
"g",
);
output = output.replace(pattern, `"name": "${original}"`);
}
for (const [full, base] of MODEL_ID_REVERSE_OVERRIDES.entries()) {
const pattern = new RegExp(
`"model"\\s*:\\s*"${escapeRegExp(full)}"`,
"g",
);
output = output.replace(pattern, `"model": "${base}"`);
}
return output;
}
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The replaceToolNamesInText function performs string replacement on streaming data using multiple regex patterns (lines 85-104). Each chunk of the stream is processed independently, which means if a JSON object boundary falls across chunk boundaries, the replacements may miss patterns or produce invalid JSON. For example, if one chunk ends with "name": "Ba and the next starts with sh", the pattern won't match. Consider buffering incomplete JSON structures or using a proper streaming JSON parser instead of regex-based text replacement.

Copilot uses AI. Check for mistakes.
Comment on lines 161 to 460
function installAnthropicFetchPatch(getAuth, client) {
if (FETCH_PATCH_STATE.installed) {
if (getAuth) FETCH_PATCH_STATE.getAuth = getAuth;
if (client) FETCH_PATCH_STATE.client = client;
return;
}
if (!globalThis.fetch) return;
FETCH_PATCH_STATE.installed = true;
FETCH_PATCH_STATE.getAuth = getAuth ?? null;
FETCH_PATCH_STATE.client = client ?? null;
const baseFetch = getBaseFetch();

const patchedFetch = async (input, init) => {
let requestUrl = null;
try {
if (typeof input === "string" || input instanceof URL) {
requestUrl = new URL(input.toString());
} else if (input instanceof Request) {
requestUrl = new URL(input.url);
}
} catch {
requestUrl = null;
}

if (!requestUrl || requestUrl.hostname !== "api.anthropic.com") {
return baseFetch(input, init);
}

const requestInit = init ?? {};
const requestHeaders = new Headers();
if (input instanceof Request) {
input.headers.forEach((value, key) => {
requestHeaders.set(key, value);
});
}
if (requestInit.headers) {
if (requestInit.headers instanceof Headers) {
requestInit.headers.forEach((value, key) => {
requestHeaders.set(key, value);
});
} else if (Array.isArray(requestInit.headers)) {
for (const [key, value] of requestInit.headers) {
if (typeof value !== "undefined") {
requestHeaders.set(key, String(value));
}
}
} else {
for (const [key, value] of Object.entries(requestInit.headers)) {
if (typeof value !== "undefined") {
requestHeaders.set(key, String(value));
}
}
}
}

let auth = null;
try {
auth = await ensureOAuthAccess(
FETCH_PATCH_STATE.getAuth,
FETCH_PATCH_STATE.client,
);
} catch {
auth = null;
}

const authorization = requestHeaders.get("authorization") ?? "";
const shouldPatch =
auth?.type === "oauth" || authorization.includes("sk-ant-oat");
if (!shouldPatch) {
return baseFetch(input, init);
}

const incomingBeta = requestHeaders.get("anthropic-beta") || "";
const incomingBetasList = incomingBeta
.split(",")
.map((b) => b.trim())
.filter(Boolean);
let mergedBetasList = [...incomingBetasList];

if (requestUrl.pathname === "/v1/messages") {
mergedBetasList = [
"oauth-2025-04-20",
"interleaved-thinking-2025-05-14",
];
} else if (requestUrl.pathname === "/v1/messages/count_tokens") {
mergedBetasList = [
"claude-code-20250219",
"oauth-2025-04-20",
"interleaved-thinking-2025-05-14",
"token-counting-2024-11-01",
];
} else if (
requestUrl.pathname.startsWith("/api/") &&
requestUrl.pathname !== "/api/hello"
) {
mergedBetasList = ["oauth-2025-04-20"];
}

if (auth?.type === "oauth" && auth.access) {
requestHeaders.set("authorization", `Bearer ${auth.access}`);
}
if (mergedBetasList.length > 0) {
requestHeaders.set("anthropic-beta", mergedBetasList.join(","));
} else {
requestHeaders.delete("anthropic-beta");
}
requestHeaders.set("user-agent", "claude-cli/2.1.2 (external, cli)");
requestHeaders.set("x-app", "cli");
requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");

const env = globalThis.process?.env ?? {};
const platform = globalThis.process?.platform ?? "linux";
const os =
env.OPENCODE_STAINLESS_OS ??
(platform === "darwin"
? "Darwin"
: platform === "win32"
? "Windows"
: platform === "linux"
? "Linux"
: platform);

requestHeaders.set(
"x-stainless-arch",
env.OPENCODE_STAINLESS_ARCH ?? globalThis.process?.arch ?? "x64",
);
requestHeaders.set("x-stainless-lang", env.OPENCODE_STAINLESS_LANG ?? "js");
requestHeaders.set("x-stainless-os", os);
requestHeaders.set(
"x-stainless-package-version",
env.OPENCODE_STAINLESS_PACKAGE_VERSION ?? "0.70.0",
);
requestHeaders.set(
"x-stainless-runtime",
env.OPENCODE_STAINLESS_RUNTIME ?? "node",
);
requestHeaders.set(
"x-stainless-runtime-version",
env.OPENCODE_STAINLESS_RUNTIME_VERSION ??
globalThis.process?.version ??
"v24.3.0",
);
requestHeaders.set(
"x-stainless-retry-count",
env.OPENCODE_STAINLESS_RETRY_COUNT ?? "0",
);
requestHeaders.set(
"x-stainless-timeout",
env.OPENCODE_STAINLESS_TIMEOUT ?? "600",
);
requestHeaders.delete("x-api-key");

let body = requestInit.body;
if (!body && input instanceof Request) {
try {
body = await input.clone().text();
} catch {
body = requestInit.body;
}
}

let shouldSetHelperMethod = false;
if (body && typeof body === "string") {
try {
const parsed = JSON.parse(body);
if (parsed.model) {
parsed.model = normalizeModelId(parsed.model);
}
if (parsed.tools && Array.isArray(parsed.tools)) {
parsed.tools = parsed.tools.map((tool) => ({
...tool,
name: tool.name ? normalizeToolNameForClaude(tool.name) : tool.name,
}));
} else if (parsed.tools && typeof parsed.tools === "object") {
const mappedTools = {};
for (const [key, value] of Object.entries(parsed.tools)) {
const mappedKey = normalizeToolNameForClaude(key);
const mappedValue =
value && typeof value === "object"
? {
...value,
name: value.name
? normalizeToolNameForClaude(value.name)
: mappedKey,
}
: value;
mappedTools[mappedKey] = mappedValue;
}
parsed.tools = mappedTools;
}
if (parsed.tool_choice) {
delete parsed.tool_choice;
}

if (requestUrl.pathname === "/v1/messages") {
const metadataUserId = await resolveMetadataUserId();
if (metadataUserId) {
if (!parsed.metadata || typeof parsed.metadata !== "object") {
parsed.metadata = {};
}
if (!parsed.metadata.user_id) {
parsed.metadata.user_id = metadataUserId;
}
}
}

if (parsed.stream) shouldSetHelperMethod = true;
body = JSON.stringify(parsed);
} catch {
// ignore parse errors
}
}

if (shouldSetHelperMethod) {
requestHeaders.set("x-stainless-helper-method", "stream");
}

if (
(requestUrl.pathname === "/v1/messages" ||
requestUrl.pathname === "/v1/messages/count_tokens") &&
!requestUrl.searchParams.has("beta")
) {
requestUrl.searchParams.set("beta", "true");
}

let requestInput = requestUrl;
let requestInitOut = {
...requestInit,
headers: requestHeaders,
body,
};

if (input instanceof Request) {
requestInput = new Request(requestUrl.toString(), {
...requestInit,
headers: requestHeaders,
body,
});
requestInitOut = undefined;
}

const response = await baseFetch(requestInput, requestInitOut);
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
const encoder = new TextEncoder();

const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}

let text = decoder.decode(value, { stream: true });
text = replaceToolNamesInText(text);
controller.enqueue(encoder.encode(text));
},
});

return new Response(stream, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}

return response;
};

patchedFetch.__opencodeAnthropicPatched = true;
globalThis.fetch = patchedFetch;
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The installAnthropicFetchPatch function mutates the global globalThis.fetch (line 433), which affects all code in the same JavaScript runtime, not just OpenCode's Anthropic requests. If any other code in the process makes fetch requests to api.anthropic.com (for example, a monitoring service or another library), those requests will be unexpectedly modified by this patch. This could lead to hard-to-debug issues. Consider using a more localized approach or documenting this global side effect clearly.

Copilot uses AI. Check for mistakes.
Comment on lines 161 to 461
function installAnthropicFetchPatch(getAuth, client) {
if (FETCH_PATCH_STATE.installed) {
if (getAuth) FETCH_PATCH_STATE.getAuth = getAuth;
if (client) FETCH_PATCH_STATE.client = client;
return;
}
if (!globalThis.fetch) return;
FETCH_PATCH_STATE.installed = true;
FETCH_PATCH_STATE.getAuth = getAuth ?? null;
FETCH_PATCH_STATE.client = client ?? null;
const baseFetch = getBaseFetch();

const patchedFetch = async (input, init) => {
let requestUrl = null;
try {
if (typeof input === "string" || input instanceof URL) {
requestUrl = new URL(input.toString());
} else if (input instanceof Request) {
requestUrl = new URL(input.url);
}
} catch {
requestUrl = null;
}

if (!requestUrl || requestUrl.hostname !== "api.anthropic.com") {
return baseFetch(input, init);
}

const requestInit = init ?? {};
const requestHeaders = new Headers();
if (input instanceof Request) {
input.headers.forEach((value, key) => {
requestHeaders.set(key, value);
});
}
if (requestInit.headers) {
if (requestInit.headers instanceof Headers) {
requestInit.headers.forEach((value, key) => {
requestHeaders.set(key, value);
});
} else if (Array.isArray(requestInit.headers)) {
for (const [key, value] of requestInit.headers) {
if (typeof value !== "undefined") {
requestHeaders.set(key, String(value));
}
}
} else {
for (const [key, value] of Object.entries(requestInit.headers)) {
if (typeof value !== "undefined") {
requestHeaders.set(key, String(value));
}
}
}
}

let auth = null;
try {
auth = await ensureOAuthAccess(
FETCH_PATCH_STATE.getAuth,
FETCH_PATCH_STATE.client,
);
} catch {
auth = null;
}

const authorization = requestHeaders.get("authorization") ?? "";
const shouldPatch =
auth?.type === "oauth" || authorization.includes("sk-ant-oat");
if (!shouldPatch) {
return baseFetch(input, init);
}

const incomingBeta = requestHeaders.get("anthropic-beta") || "";
const incomingBetasList = incomingBeta
.split(",")
.map((b) => b.trim())
.filter(Boolean);
let mergedBetasList = [...incomingBetasList];

if (requestUrl.pathname === "/v1/messages") {
mergedBetasList = [
"oauth-2025-04-20",
"interleaved-thinking-2025-05-14",
];
} else if (requestUrl.pathname === "/v1/messages/count_tokens") {
mergedBetasList = [
"claude-code-20250219",
"oauth-2025-04-20",
"interleaved-thinking-2025-05-14",
"token-counting-2024-11-01",
];
} else if (
requestUrl.pathname.startsWith("/api/") &&
requestUrl.pathname !== "/api/hello"
) {
mergedBetasList = ["oauth-2025-04-20"];
}

if (auth?.type === "oauth" && auth.access) {
requestHeaders.set("authorization", `Bearer ${auth.access}`);
}
if (mergedBetasList.length > 0) {
requestHeaders.set("anthropic-beta", mergedBetasList.join(","));
} else {
requestHeaders.delete("anthropic-beta");
}
requestHeaders.set("user-agent", "claude-cli/2.1.2 (external, cli)");
requestHeaders.set("x-app", "cli");
requestHeaders.set("anthropic-dangerous-direct-browser-access", "true");

const env = globalThis.process?.env ?? {};
const platform = globalThis.process?.platform ?? "linux";
const os =
env.OPENCODE_STAINLESS_OS ??
(platform === "darwin"
? "Darwin"
: platform === "win32"
? "Windows"
: platform === "linux"
? "Linux"
: platform);

requestHeaders.set(
"x-stainless-arch",
env.OPENCODE_STAINLESS_ARCH ?? globalThis.process?.arch ?? "x64",
);
requestHeaders.set("x-stainless-lang", env.OPENCODE_STAINLESS_LANG ?? "js");
requestHeaders.set("x-stainless-os", os);
requestHeaders.set(
"x-stainless-package-version",
env.OPENCODE_STAINLESS_PACKAGE_VERSION ?? "0.70.0",
);
requestHeaders.set(
"x-stainless-runtime",
env.OPENCODE_STAINLESS_RUNTIME ?? "node",
);
requestHeaders.set(
"x-stainless-runtime-version",
env.OPENCODE_STAINLESS_RUNTIME_VERSION ??
globalThis.process?.version ??
"v24.3.0",
);
requestHeaders.set(
"x-stainless-retry-count",
env.OPENCODE_STAINLESS_RETRY_COUNT ?? "0",
);
requestHeaders.set(
"x-stainless-timeout",
env.OPENCODE_STAINLESS_TIMEOUT ?? "600",
);
requestHeaders.delete("x-api-key");

let body = requestInit.body;
if (!body && input instanceof Request) {
try {
body = await input.clone().text();
} catch {
body = requestInit.body;
}
}

let shouldSetHelperMethod = false;
if (body && typeof body === "string") {
try {
const parsed = JSON.parse(body);
if (parsed.model) {
parsed.model = normalizeModelId(parsed.model);
}
if (parsed.tools && Array.isArray(parsed.tools)) {
parsed.tools = parsed.tools.map((tool) => ({
...tool,
name: tool.name ? normalizeToolNameForClaude(tool.name) : tool.name,
}));
} else if (parsed.tools && typeof parsed.tools === "object") {
const mappedTools = {};
for (const [key, value] of Object.entries(parsed.tools)) {
const mappedKey = normalizeToolNameForClaude(key);
const mappedValue =
value && typeof value === "object"
? {
...value,
name: value.name
? normalizeToolNameForClaude(value.name)
: mappedKey,
}
: value;
mappedTools[mappedKey] = mappedValue;
}
parsed.tools = mappedTools;
}
if (parsed.tool_choice) {
delete parsed.tool_choice;
}

if (requestUrl.pathname === "/v1/messages") {
const metadataUserId = await resolveMetadataUserId();
if (metadataUserId) {
if (!parsed.metadata || typeof parsed.metadata !== "object") {
parsed.metadata = {};
}
if (!parsed.metadata.user_id) {
parsed.metadata.user_id = metadataUserId;
}
}
}

if (parsed.stream) shouldSetHelperMethod = true;
body = JSON.stringify(parsed);
} catch {
// ignore parse errors
}
}

if (shouldSetHelperMethod) {
requestHeaders.set("x-stainless-helper-method", "stream");
}

if (
(requestUrl.pathname === "/v1/messages" ||
requestUrl.pathname === "/v1/messages/count_tokens") &&
!requestUrl.searchParams.has("beta")
) {
requestUrl.searchParams.set("beta", "true");
}

let requestInput = requestUrl;
let requestInitOut = {
...requestInit,
headers: requestHeaders,
body,
};

if (input instanceof Request) {
requestInput = new Request(requestUrl.toString(), {
...requestInit,
headers: requestHeaders,
body,
});
requestInitOut = undefined;
}

const response = await baseFetch(requestInput, requestInitOut);
if (response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
const encoder = new TextEncoder();

const stream = new ReadableStream({
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}

let text = decoder.decode(value, { stream: true });
text = replaceToolNamesInText(text);
controller.enqueue(encoder.encode(text));
},
});

return new Response(stream, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}

return response;
};

patchedFetch.__opencodeAnthropicPatched = true;
globalThis.fetch = patchedFetch;
}
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The installAnthropicFetchPatch function duplicates significant logic that already exists in the loader.fetch override (lines 571-843). Both functions perform identical operations: header manipulation, beta header construction, tool name normalization, model ID normalization, metadata injection, and streaming response transformation. This creates a maintenance burden where bug fixes or changes must be applied to both locations. Consider extracting the shared logic into reusable helper functions or consolidating into a single implementation.

Copilot uses AI. Check for mistakes.
index.mjs Outdated
Comment on lines 409 to 446
async pull(controller) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}

let text = decoder.decode(value, { stream: true });
text = replaceToolNamesInText(text);
controller.enqueue(encoder.encode(text));
},
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the streaming response transformation, the decoder is called with { stream: true } on line 416, but the controller never calls decoder.decode('', { stream: false }) after the stream ends. This can cause incomplete character sequences at the end of the stream to be lost. When the final chunk is read and done is true (line 411), the controller closes immediately without flushing any remaining bytes in the decoder's internal buffer. Consider decoding any remaining bytes before closing the controller.

Copilot uses AI. Check for mistakes.
Comment on lines 910 to 1101
async "chat.params"(input, output) {
const providerId = input.provider?.id ?? "";
if (providerId && !providerId.includes("anthropic")) return;

const options = output.options ?? {};
output.options = options;

const env = globalThis.process?.env ?? {};
const platform = globalThis.process?.platform ?? "linux";
const os =
env.OPENCODE_STAINLESS_OS ??
(platform === "darwin"
? "Darwin"
: platform === "win32"
? "Windows"
: platform === "linux"
? "Linux"
: platform);

const existingHeaders = options.headers;
const headers =
existingHeaders instanceof Headers
? new Headers(existingHeaders)
: { ...(existingHeaders ?? {}) };

const getHeader = (name) => {
if (headers instanceof Headers) return headers.get(name);
const lower = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
if (key.toLowerCase() === lower) return value;
}
return undefined;
};

const setHeader = (name, value) => {
if (!value) return;
if (headers instanceof Headers) {
headers.set(name, value);
return;
}
headers[name] = value;
};

const incomingBeta = getHeader("anthropic-beta") || "";
const incomingBetasList = incomingBeta
.split(",")
.map((b) => b.trim())
.filter(Boolean);
const mergedBetasList = incomingBetasList.filter((beta) =>
[
"oauth-2025-04-20",
"interleaved-thinking-2025-05-14",
"claude-code-20250219",
].includes(beta),
);
if (!mergedBetasList.includes("oauth-2025-04-20")) {
mergedBetasList.push("oauth-2025-04-20");
}
if (!mergedBetasList.includes("interleaved-thinking-2025-05-14")) {
mergedBetasList.push("interleaved-thinking-2025-05-14");
}
if (mergedBetasList.length > 0) {
setHeader("anthropic-beta", mergedBetasList.join(","));
}

setHeader("user-agent", "claude-cli/2.1.2 (external, cli)");
setHeader("x-app", "cli");
setHeader("anthropic-dangerous-direct-browser-access", "true");
setHeader(
"x-stainless-arch",
env.OPENCODE_STAINLESS_ARCH ?? globalThis.process?.arch ?? "x64",
);
setHeader("x-stainless-lang", env.OPENCODE_STAINLESS_LANG ?? "js");
setHeader("x-stainless-os", os);
setHeader(
"x-stainless-package-version",
env.OPENCODE_STAINLESS_PACKAGE_VERSION ?? "0.70.0",
);
setHeader(
"x-stainless-runtime",
env.OPENCODE_STAINLESS_RUNTIME ?? "node",
);
setHeader(
"x-stainless-runtime-version",
env.OPENCODE_STAINLESS_RUNTIME_VERSION ??
globalThis.process?.version ??
"v24.3.0",
);
setHeader(
"x-stainless-retry-count",
env.OPENCODE_STAINLESS_RETRY_COUNT ?? "0",
);
setHeader(
"x-stainless-timeout",
env.OPENCODE_STAINLESS_TIMEOUT ?? "600",
);
if (options.stream && !getHeader("x-stainless-helper-method")) {
setHeader("x-stainless-helper-method", "stream");
}

options.headers = headers;

const metadataUserId = await resolveMetadataUserId();
if (metadataUserId) {
const metadata =
options.metadata && typeof options.metadata === "object"
? { ...options.metadata }
: {};
if (!metadata.user_id) metadata.user_id = metadataUserId;
options.metadata = metadata;
}

const selectedModel = options.model ?? input.model?.id;
if (selectedModel) {
options.model = normalizeModelId(selectedModel);
}

if (Array.isArray(options.tools)) {
options.tools = options.tools.map((tool) => ({
...tool,
name: tool?.name ? normalizeToolNameForClaude(tool.name) : tool?.name,
}));
} else if (options.tools && typeof options.tools === "object") {
const mappedTools = {};
for (const [key, value] of Object.entries(options.tools)) {
const mappedKey = normalizeToolNameForClaude(key);
const mappedValue =
value && typeof value === "object"
? {
...value,
name: value.name
? normalizeToolNameForClaude(value.name)
: mappedKey,
}
: value;
mappedTools[mappedKey] = mappedValue;
}
options.tools = mappedTools;
}
if (options.tool_choice) {
delete options.tool_choice;
}
},
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The chat.params hook duplicates significant logic from both installAnthropicFetchPatch and the loader.fetch override: header construction (lines 917-1010), metadata injection (lines 1012-1020), model ID normalization (lines 1022-1025), tool name normalization (lines 1027-1048), and tool_choice deletion (lines 1049-1051). This is the third location where this logic appears, creating a severe maintenance burden. Any changes to the request normalization logic must now be replicated across three separate functions.

Copilot uses AI. Check for mistakes.
index.mjs Outdated
Comment on lines 35 to 45
function normalizeToolNameForClaude(name) {
if (!name) return name;
const mapped = CLAUDE_CODE_TOOL_NAMES.get(name.toLowerCase());
const pascal = mapped ?? toPascalCase(name);
if (pascal && pascal !== name) {
TOOL_NAME_CACHE.set(pascal, name);
}
return pascal;
}
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tool name cache (TOOL_NAME_CACHE) grows unbounded. Every time a tool name is normalized via normalizeToolNameForClaude, the mapping is stored in the cache (lines 39-41), but the cache is never cleared. In a long-running process that handles many different tools, this could lead to excessive memory usage. Consider implementing a cache size limit or using a WeakMap if the tool names are object-based, or periodically clearing the cache.

Copilot uses AI. Check for mistakes.
index.mjs Outdated
Comment on lines 369 to 397
} catch {
// ignore parse errors
Copy link

Copilot AI Jan 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When parsing the request body, if JSON parsing fails (line 369), the error is silently caught and ignored. However, if the body was intended to be JSON but is malformed, this could result in requests being sent with incorrect data. At minimum, consider logging parse errors in development mode or adding a comment explaining why it's safe to ignore these errors (e.g., if non-JSON bodies are expected).

Suggested change
} catch {
// ignore parse errors
} catch (error) {
// Some requests legitimately have non-JSON string bodies; in those cases JSON.parse will
// fail and we intentionally leave the body unchanged. Log in development to aid debugging
// when malformed JSON was intended.
if (
typeof process !== "undefined" &&
process?.env?.NODE_ENV !== "production"
) {
// eslint-disable-next-line no-console
console.warn(
"Failed to parse request body as JSON; sending original body unmodified.",
error,
);
}

Copilot uses AI. Check for mistakes.
Normalize Anthropic requests to Claude Code headers, betas, metadata, model IDs,
and tool casing, while rewriting streaming responses back to OpenCode expectations
so UI rendering and OAuth validation succeed.
@stranger2904
Copy link

any particular reason why this would only allow to access some sonnet model even when opus is requested ?

@deveworld
Copy link
Author

That's likely a subscription tier limit,
The code maps model IDs correctly:

  • claude-opus-4-5 → claude-opus-4-5-20251101
  • claude-haiku-4-5 → claude-haiku-4-5-20251001

What's your plan tier? Some tiers have Opus rate limits.

- Fix SSE stream chunk boundary issue with event-based buffering
- Add null check for URL extraction in handleAnthropicRequest
- Use baseFetch in exchange() and create_api_key to prevent infinite loop
- Add TOOL_NAME_CACHE size limit (1000) with LRU-like eviction
- Add debug logging (OPENCODE_DEBUG=true) instead of silent error swallowing
- Fix code#state parsing to handle edge cases safely
- Add explanatory comments for intentional mutations and API limitations
- Refactor duplicated logic into shared helper functions
@stranger2904
Copy link

That's likely a subscription tier limit, The code maps model IDs correctly:

  • claude-opus-4-5 → claude-opus-4-5-20251101
  • claude-haiku-4-5 → claude-haiku-4-5-20251001

What's your plan tier? Some tiers have Opus rate limits.

Max20, claude code is ok, plenty of quotas left

@deveworld
Copy link
Author

Can you share more details?

  1. What model do you have selected in OpenCode settings?
  2. What's the exact error or behavior? (Does Opus request return error, or does it work but response says it's Sonnet?)

Sonnet is missing from MODEL_ID_OVERRIDES, I'll add it if that's the issue.

@deveworld
Copy link
Author

deveworld commented Jan 9, 2026

Just added Sonnet mapping. Can you pull the latest and test? @stranger2904

  • claude-sonnet-4-5 → claude-sonnet-4-5-20250929

@stranger2904
Copy link

Integrated latest, still the same:
% opencode run --model anthropic/claude-opus-4-5 "What's your model name, OPUS or sonnet, reply one word only"

Sonnet

% opencode run --model anthropic/claude-opus-4-5-20251101 "What's your model name, OPUS or sonnet, reply one word only"

Sonnet
% claude --model claude-opus-4-5 --print "What's your model name, OPUS or sonnet, reply one word only"

Opus

@deveworld
Copy link
Author

Investigated this, and confirmed it's not a PR #15 issue.

Claude Code injects model info into system prompt:

"You are powered by the model named Opus 4.5. The exact model ID is claude-opus-4-5-20251101."

OpenCode doesn't do this. Claude can't see the API request's model parameter directly, so without this injection it doesn't know what model it is.

This is an OpenCode core issue, not the auth plugin.

@stranger2904
Copy link

So this one is actually something I was investigating as well at some point a few weeks ago for other project.
Yes, this question might be not the best one (though it’s the easiest to evaluate)

But you can also do: “What’s your knowledge cutoff date” “when was opus 4.5 released”

my understanding that it might be something with TLS fingerprints/ or pre-API request which Claude code is sending even before first prompt being sent out.
My working theory is that either something is still not quite right with the way this plugin operates or that Anthropic detect that it’s not a claude code instance and silently pushes request to some older sonnet 3.5/4 model

@stranger2904
Copy link

I can say that prior to yesterday- replies from both open code / Claude code were matching

@deveworld
Copy link
Author

So this one is actually something I was investigating as well at some point a few weeks ago for other project. Yes, this question might be not the best one (though it’s the easiest to evaluate)

But you can also do: “What’s your knowledge cutoff date” “when was opus 4.5 released”

my understanding that it might be something with TLS fingerprints/ or pre-API request which Claude code is sending even before first prompt being sent out. My working theory is that either something is still not quite right with the way this plugin operates or that Anthropic detect that it’s not a claude code instance and silently pushes request to some older sonnet 3.5/4 model

Interesting theory, but I compared the actual traffic via mitm:

Claude Code system prompt:

"You are powered by the model named Opus 4.5. The exact model ID is claude-opus-4-5-20251101."

OpenCode system prompt:

(no model info)

The API request body shows model: claude-opus-4-5-20251101 correctly in both cases. The difference is purely in the system prompt injection.

Claude can't introspect the API request's model parameter, it only knows what's in its context. So when asked "what model are you", it's guessing based on its training, not actual runtime info.

If Anthropic were silently routing to Sonnet, the response quality/speed would be noticeably different, and the API response headers would likely differ too.

This is fixable in OpenCode core by injecting model info into the system prompt like Claude Code does.

@stranger2904
Copy link

Ok, I have mitm setup as well, let me try to test this

@aw338WoWmUI
Copy link

Is it possible to use anthropic agent sdk to power opencode just for anthropic's model? I think this will be a perma and risky free solution.

@deveworld
Copy link
Author

@aw338WoWmUI No, Claude Agent SDK explicitly doesn't support OAuth/subscription auth:

From docs:

"Unless previously approved, we do not allow third party developers to offer Claude.ai login or rate limits for their products, including agents built on the Claude Agent SDK."

Only API key auth is supported. Same policy, different wrapper.

@seaweeduk
Copy link

Isn't this missing the "AskUserQuestion" tool in CLAUDE_CODE_TOOL_NAMES? opencode has an equivalent tool called question in the latest versions

@deveworld
Copy link
Author

Isn't this missing the "AskUserQuestion" tool in CLAUDE_CODE_TOOL_NAMES? opencode has an equivalent tool called question in the latest versions

I added it just now. Thanks

@coleleavitt
Copy link

This isn't the right way of doing this this is more of a hack than anything

@androolloyd
Copy link

my solution was just to proxy the request to the claude binary, so technically claude is just responding directly to the opencode plugin to intercept back to the runtime tloop

- Add accept: application/json header
- Ensure tools array is always present (empty if none)
- Remove temperature parameter (Claude Code doesn't send it)
- Strip cache_control from system blocks
@guthabbr0
Copy link

guthabbr0 commented Jan 10, 2026

(Just in case anyone doubt about this patch and re-routing or else, than maybe change the model under-the-hood) One way to fast check and know (for sure) if you are into sonnet-4.5 or 'anything else'. Set temperature and top_p, sonnet-4.5 fails (Sonnet 4.5 doesn't allow both parameters)

index.mjs Outdated
"org:create_api_key user:profile user:inference",
);
url.searchParams.set("redirect_uri", "https://console.anthropic.com/oauth/code/callback");
url.searchParams.set("scope", "org:create_api_key user:profile user:inference");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude code now adds user:sessions:claude_code scope

- Add tokenRefreshPromise caching to prevent concurrent refresh calls
- Update auth.expires after token refresh (was missing)
- Add user:sessions:claude_code to OAuth scope (matches Claude Code CLI)
- Add AskUserQuestion to response tool name regex
- Add TextDecoder flush to handle incomplete multi-byte sequences
@deveworld
Copy link
Author

Just pushed a fix addressing several issues found during code review:

Token Refresh Issues (by Copilot review)

  1. Race condition: Multiple concurrent requests could trigger duplicate token refreshes. Added Promise caching pattern to ensure single refresh.
  2. auth.expires not updated: After refresh, only auth.access was updated but auth.expires wasn't, causing repeated unnecessary refreshes.

OAuth Scope (by @aryasaatvik review)
3. Added user:sessions:claude_code to match Claude Code CLI's actual OAuth request (verified via URL inspection).

Response Transform
4. Added AskUserQuestion to response regex for proper tool name reverse mapping.

Streaming
5. Added TextDecoder flush on stream end to handle incomplete multi-byte UTF-8 sequences at chunk boundaries.

Feel free to report any error!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants