Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,27 @@ SHA256(salt + message[4] + message[7] + message[20] + CLI_VERSION).slice(0, 3)

The salt is hardcoded in Claude Code's source. This changes per conversation because the user's message text changes.

## OpenCode v2 compatibility

OpenCode 2.0 loads plugins through a new contract (`id` + `setup()`), and V1
hooks never run there — the plugin fails with *"Plugin must export a default
definition with an id and an effect or setup function"*. This package supports
both generations from one entrypoint:

- **OpenCode ≥ 2.0** — uses `setup()`. The bridge registers an OAuth method
override on the built-in `anthropic` integration (authorize + layered
refresh, so `claude login` keeps auto-syncing) and rewrites requests through
the `http.request` / `http.response` hooks. No model injection: the v2
catalog already lists Claude models, and a stored OAuth credential
activates the provider. Reconnect via `/connect` → Anthropic if needed.
- **OpenCode 1.18 – 1.x** — uses the V1 `server()` implementation as before.

OpenCode < 1.18 is not supported by `main` (the `{ id, server }` entrypoint
shape requires 1.18.29); use the 1.10.x releases there.

## Requirements

- [OpenCode](https://opencode.ai) v1.2+
- [OpenCode](https://opencode.ai) v1.18.29+ (v2 supported)
- For OAuth: [Claude CLI](https://docs.anthropic.com/en/docs/claude-code) installed and logged in (`claude login`)
- macOS (Keychain) or Linux (`~/.claude/.credentials.json` fallback)
- For API key: just configure a `provider` with `apiKey` in your OpenCode config as usual
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "opencode-claude-bridge",
"version": "1.10.12",
"version": "1.11.0",
"description": "OpenCode ↔ Claude bridge — keychain sync + PKCE OAuth + request transformation",
"type": "module",
"main": "dist/index.js",
Expand Down
14 changes: 12 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,21 @@ export const USER_AGENT =
process.env.ANTHROPIC_USER_AGENT ||
`claude-cli/${CLI_VERSION} (external, ${ENTRYPOINT})`;

// Exact beta flags from Claude Code 2.1.178 (confirmed via request interception)
// Beta flags matching Claude Code 2.1.x (confirmed via request interception).
// oauth-2025-04-20 is added separately only for OAuth auth.
//
// 2026-10-16: added thinking-binding-controls-2026-08-01 (present in Claude
// Code ≥2.1.2xx). It legalizes the `thinking.block_binding` request field,
// which OpenCode v2 sends natively for models flagged
// supportsThinkingBlockBinding (claude-haiku-5-5, opus/sonnet/fable 5.x).
// Without this flag those requests fail validation with:
// "thinking.adaptive.block_binding: Extra inputs are not permitted"
// To refresh this list against a newer Claude Code install:
// grep -aoE '"[a-z]+(-[a-z]+)*-20[0-9]{2}-[0-9]{2}-[0-9]{2}"' \
// "$(readlink -f "$(command -v claude)")" | sort -u
export const BETA_FLAGS =
process.env.ANTHROPIC_BETA_FLAGS ||
"interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,advisor-tool-2026-03-01,effort-2025-11-24,extended-cache-ttl-2025-04-11";
"interleaved-thinking-2025-05-14,thinking-token-count-2026-05-13,context-management-2025-06-27,prompt-caching-scope-2026-01-05,claude-code-20250219,advisor-tool-2026-03-01,effort-2025-11-24,extended-cache-ttl-2025-04-11,thinking-binding-controls-2026-08-01";

export const OAUTH_BETA_FLAG = "oauth-2025-04-20";

Expand Down
61 changes: 60 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1227,17 +1227,76 @@ describe("rewriteSystemBlocksForModel", () => {
});

describe("plugin module shape", () => {
it("default-exports { id, server } so opencode's loader recognises it", () => {
it("default-exports { id, setup, server } so opencode's loader recognises it", () => {
// A bare function default export sends opencode down its legacy path, where
// it calls every named export of this module as a plugin factory — which
// crashes on the helpers above and takes the whole plugin down with it.
// `server` keeps the V1 implementation on opencode >= 1.18; `setup` adds
// opencode >= 2, which ignores `server` and never runs V1 hooks.
assert.equal(typeof pluginModule, "object");
assert.equal(typeof pluginModule.server, "function");
assert.equal(typeof pluginModule.setup, "function");
});

it("carries an id, which opencode requires for path-referenced plugins", () => {
assert.equal(pluginModule.id, "opencode-claude-bridge");
});

it("v2 setup registers the anthropic oauth method override and provider-scoped hooks", async () => {
const registrations: string[] = [];
const methodUpdate = { capture: null as unknown };
const hooks: Array<{ name: string; options?: { providerID?: string } }> = [];
const ctx = {
integration: {
transform: async (editor: (editor: {
method: {
update(registration: Record<string, unknown>): void;
};
}) => void) => {
editor({
method: {
update(registration: Record<string, unknown>) {
methodUpdate.capture = registration;
registrations.push("integration.transform");
},
},
});
},
connection: {
active: async () => undefined,
resolve: async () => undefined,
},
},
session: {
hook: async (name: string, _cb: unknown, options?: { providerID?: string }) => {
hooks.push({ name, options });
registrations.push(`session.hook(${name})`);
},
},
};
const cleanup = await (pluginModule.setup as (c: unknown) => Promise<unknown>)(ctx);

const registration = methodUpdate.capture as Record<string, unknown>;
assert.equal(registration.integrationID, "anthropic");
const method = registration.method as Record<string, unknown>;
assert.equal(method.id, "oauth");
assert.equal(method.type, "oauth");
assert.equal(typeof registration.authorize, "function");
assert.equal(typeof registration.refresh, "function");
// The refresh callback must be async — opencode calls .then() on its result
// (a plain-object return crashes the credential resolver).
assert.equal(
(registration.refresh as (...args: unknown[]) => unknown).constructor.name,
"AsyncFunction",
"refresh must be async so opencode can await it",
);

assert.deepEqual(
hooks.map((h) => `${h.name}:${h.options?.providerID ?? ""}`).sort(),
["context:anthropic", "http.request:anthropic", "http.response:anthropic"],
);
assert.equal(typeof cleanup, "function");
});
});

describe("selectClaudeCredentials", () => {
Expand Down
Loading