Skip to content
Draft
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
19 changes: 19 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions docs/docs/configure/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,47 @@ identity.

Available models: `claude-opus-4-6`, `claude-sonnet-4-6`, `claude-haiku-4-5-20251001`

## Claude Agent (Claude subscription)

Claude Agent runs Claude models through your own Claude Code login, so a Claude Pro or Max subscription covers usage. Altimate Code starts your installed `claude` program through Anthropic's Claude Agent SDK. It never reads or stores your Claude Code token.

1. Install [Claude Code](https://code.claude.com/docs/en/setup) (2.1.248 or later) and run `claude auth login`.
2. In Altimate Code, run `/connect` and pick **Claude Agent**. The dialog checks your Claude Code login and connects once it finds one.
3. Pick a model with `/models`. The list is what your Claude Code reports, such as `claude-agent/opus`, `claude-agent/sonnet` and `claude-agent/haiku`, and each model offers Claude Code's effort levels as variants.

Any Claude Code login works: a subscription, an API key or a cloud account. A subscription shows $0 cost. An API key or cloud login is priced from that provider's catalog.

How it behaves:

- Claude uses Altimate Code's tools, permissions, agents, skills and `AGENTS.md` instructions. Claude Code's own tools, settings files, `CLAUDE.md`, hooks, MCP servers, memory and auto compaction are turned off.
- Chats use Claude Code's system prompt plus the selected agent's prompt as its role. Temperature, top-p and top-k are not supported.
- You can switch between Claude Agent and other models in the same session. Claude is sent the part of the conversation it has not seen.
- Each session keeps one `claude` process, which stops after 5 minutes idle and resumes the saved Claude session on the next message.
- `claude` must be installed on the same machine. Set `OPENCODE_CLAUDE_PATH` to run a specific `claude` binary instead of the one on `PATH`.

Connection settings in config map to Claude Code variables:

```json
{
"provider": {
"claude-agent": {
"options": {
"baseURL": "https://gateway.example.com",
"headers": { "x-team": "data" }
}
}
}
}
```

| Option | Claude Code variable |
|--------|---------------------|
| `baseURL` | `ANTHROPIC_BASE_URL` |
| `apiKey` | `ANTHROPIC_API_KEY` |
| `authToken` | `ANTHROPIC_AUTH_TOKEN` |
| `headers` | `ANTHROPIC_CUSTOM_HEADERS` |
| `body` | `CLAUDE_CODE_EXTRA_BODY` |

## OpenAI

```json
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"@ai-sdk/xai": "3.0.82",
"@altimateai/altimate-core": "0.9.0",
"@altimateai/drivers": "workspace:*",
"@anthropic-ai/claude-agent-sdk": "0.3.301",
"@aws-sdk/credential-providers": "3.1057.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/opentelemetry": "catalog:",
Expand Down
83 changes: 83 additions & 0 deletions packages/opencode/src/altimate/provider/claude-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Claude Agent provider

<!-- HUMAN-AUTHORED: Start -->
<!-- This section is manually maintained. AI should preserve this content. -->

<!-- HUMAN-AUTHORED: End -->

<!-- AI-GENERATED: Start -->
<!-- This section is automatically maintained by Claude Code. -->
<!-- Last updated: 2026-11-11 by Claude Code -->

## Purpose

Runs Claude models through the user's own Claude Code login (`claude auth login`), so a Claude Pro/Max
subscription covers usage. The CLI starts the user's installed `claude` through
`@anthropic-ai/claude-agent-sdk`, turns off Claude Code's own tools and settings, and gives Claude the
CLI's tools over an in-process MCP server. It never reads Claude Code's token.

Ported from upstream opencode PR 58782, "feat(core): add Claude Agent provider" (merge commit
`f29e281322b`). Upstream builds it in
`packages/core/src/plugin/provider/claude-agent/` on core's v2 plugin context. The shipped CLI does not
run that runtime, so the upstream modules that only depend on the SDK live here, and the plugin-context
wiring is replaced by the call sites below.

## Layout

Copied from upstream with import paths adjusted (compare against the upstream folder in a bridge merge):

- `claude/` — `claude` process lifecycle: probe, per-session processes, status, forks, SDK loading.
- `sync/` — matches the CLI's history with Claude's saved session (catch-up, checkpoints, branches).
- `tools/` — the MCP tool server, PreToolUse/PostToolUse hooks, held tool calls and results.
- `language-model/` — the `LanguageModelV3` the AI SDK calls, and Claude stream conversion.

Altimate-only integration (replaces upstream `index.ts`, `provider.ts`, `aisdk.ts`, `integration.ts`,
`session.ts`, `instructions.ts`):

- `id.ts` — provider ID `claude-agent` and package name `@opencode/claude-agent`.
- `index.ts` — `ClaudeAgent`: session registry per instance, AI SDK factory, login check, request routing.
- `catalog.ts` — saved model list from Claude Code, pricing by login type, `Provider.Model` entries.
- `storage.ts` — JSON files under `<data>/claude-agent/` (model list, per-session Claude records).
- `plugin.ts` — internal plugin: `/connect` login method, session-idle release, config connection.

## Call sites (all inside `altimate_change` markers)

- `provider/provider.ts` — bundled SDK factory, provider database entry, `haiku` as small model.
- `provider/transform.ts` — `variants()` returns the effort levels Claude Code reported.
- `session/llm.ts` — request kind and session headers, agent role and instructions as options, no default output cap
(a plugin's explicit `maxOutputTokens` still applies).
- `session/message-v2.ts` — stopped Claude Agent tool calls carry `toolAborted` (see `tools/aborted.ts`).
- `plugin/index.ts` — registers `ClaudeAgentPlugin`.
- `packages/tui/src/component/dialog-provider.tsx` — listed under Popular with a subscription hint.

## Differences from upstream

- **Request kinds:** titles, summaries and compaction run as standalone side calls with the CLI's prompt.
Upstream copies the chat's Claude session for summaries and compaction to reuse its prompt cache; the
shipped runtime has no hook to snapshot a session for them. Forked sessions start a fresh Claude session.
- **Tool calls:** `convert-claude-stream.ts` emits `tool-input-start/delta/end` before each `tool-call`,
because the shipped session processor only records a call whose part `tool-input-start` opened.
- **Instructions:** taken from `session/llm.ts` input (`input.system` and the user's system text) rather than
keyed instruction metadata. The per-turn environment block stays out, as upstream leaves out
`core/date` and `core/environment`.
- **Tool errors and stops:** upstream's `context` hook flags tool errors and stops in provider metadata. The
shipped runtime already sends failed tools as `error-text` results, which `tools/result.ts` marks as errors.
Stopped calls get `toolAborted` from history conversion, so the waiting Claude turn is interrupted.
- **Generate calls:** upstream only streams. `doGenerate` collects the streamed reply, because the shipped CLI
calls `generateObject`/`generateText` (agent creation, learning reflection). A JSON request is asked for in
the prompt, since Claude Code has no structured-output mode, and a markdown fence around it is removed.
- **Per-project state:** each directory gets its own session registry, connection settings and login status.
- **MCP server:** built on `@modelcontextprotocol/sdk` 1.x (already bundled) instead of `@modelcontextprotocol/server` 2.x.
- **Connection settings:** `headerTimeout` and `chunkTimeout` are consumed by the provider loader before
the factory runs, so they do not reach Claude Code.
- **Model list refresh:** a new list from a chat's `claude` start is saved and shown after the next
instance reload; `/connect` reloads immediately.
- **Branding:** model-visible text says "Altimate Code"; internal tool names keep `mcp__opencode__*`.

## Tests

`packages/opencode/test/altimate/provider/claude-agent/` — upstream's tests (except `plugin.test.ts`,
which needs core's plugin host) replaying recorded real Claude sessions through the real SDK with a fake
`claude`, plus `integration.test.ts` for the Altimate-only modules.

<!-- AI-GENERATED: End -->
145 changes: 145 additions & 0 deletions packages/opencode/src/altimate/provider/claude-agent/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
export * as ClaudeAgentCatalog from "./catalog"

import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk"
import { Option, Schema } from "effect"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import type { ClaudeProbe } from "./claude/probe"
import { ClaudeAgentID } from "./id"
import { ClaudeAgentStorage } from "./storage"

// Ported from upstream `claude-agent/provider.ts`: the model list kept between starts, so the CLI
// can list Claude models without running `claude`.
const CatalogModel = Schema.Struct({
id: Schema.String,
name: Schema.String,
efforts: Schema.Array(Schema.String),
contextWindow: Schema.optionalKey(Schema.Number),
resolvedModel: Schema.optionalKey(Schema.String),
})
type CatalogModel = typeof CatalogModel.Type
const Catalog = Schema.Struct({
priceSource: Schema.optionalKey(Schema.String),
models: Schema.Array(CatalogModel),
})
export type Catalog = typeof Catalog.Type
const KEY = "models"

// Before the first `claude` start reports its models. Claude Code resolves these aliases to its
// current models, so they stay valid as Anthropic ships new ones.
const FALLBACK: readonly CatalogModel[] = [
{ id: "opus", name: "Opus", efforts: [] },
{ id: "sonnet", name: "Sonnet", efforts: [] },
{ id: "haiku", name: "Haiku", efforts: [] },
]

const DEFAULT_CONTEXT = 200_000
const DEFAULT_OUTPUT = 32_000

// Mantle is Bedrock serving Anthropic's API, and Bedrock's list drops most `anthropic.…` IDs,
// so it uses Anthropic's prices.
const PRICE_SOURCES: Partial<Record<NonNullable<AccountInfo["apiProvider"]>, string>> = {
firstParty: "anthropic",
anthropicAws: "anthropic",
anthropicGoogleCloud: "anthropic",
mantle: "anthropic",
bedrock: "amazon-bedrock",
vertex: "google-vertex",
foundry: "azure",
}

export async function load(): Promise<Catalog> {
const saved = Schema.decodeUnknownOption(Catalog)(await ClaudeAgentStorage.read(KEY))
return Option.getOrElse(saved, (): Catalog => ({ models: [] }))
}

// Saves what a probe or a chat's startup reported. Returns whether the list changed, so the
// caller knows the provider list needs a reload to show it.
export async function update(probe: ClaudeProbe.Result, baseURL: string | undefined) {
const current = await load()
const source = priceSource(probe.account, baseURL)
const next: Catalog = {
...(source ? { priceSource: source } : {}),
models: fromClaude(probe.models, current.models),
}
if (JSON.stringify(next) === JSON.stringify(current)) return false
await ClaudeAgentStorage.write(KEY, next)
return true
}

export function provider(
catalog: Catalog,
database: Readonly<Record<string, { readonly models: Readonly<Record<string, Provider.Model>> }>>,
): Provider.Info {
const prices = catalog.priceSource ? database[catalog.priceSource]?.models : undefined
const models = catalog.models.length > 0 ? catalog.models : FALLBACK
return {
id: ProviderID.make(ClaudeAgentID.ID),
name: "Claude Agent",
source: "custom",
env: [],
options: {},
models: Object.fromEntries(models.map((model) => [model.id, toModel(model, prices)])),
}
}

// Context windows only come from the full probe, so a chat's startup report keeps the saved ones.
function fromClaude(models: readonly ClaudeProbe.Model[], saved: readonly CatalogModel[]): CatalogModel[] {
const known = new Map<string, number | undefined>()
for (const model of saved) known.set(model.id, model.contextWindow)
const result: CatalogModel[] = []
for (const model of models) {
// "default" follows Claude Code's own default, which can change under a running session.
if (model.value === "default") continue
const contextWindow = model.contextWindow ?? known.get(model.value)
result.push({
id: model.value,
name: model.displayName,
efforts: model.supportedEffortLevels ?? [],
...(contextWindow !== undefined ? { contextWindow } : {}),
...(model.resolvedModel ? { resolvedModel: model.resolvedModel } : {}),
})
}
return result
}

// A subscription covers usage, and a custom base URL or an enterprise gateway can bill anything,
// so they show no cost.
function priceSource(account: AccountInfo, baseURL: string | undefined) {
if (baseURL) return
if (account.apiProvider === undefined) return
if (account.apiProvider === "firstParty" && account.apiKeySource === undefined) return
return PRICE_SOURCES[account.apiProvider]
}

function toModel(model: CatalogModel, prices: Readonly<Record<string, Provider.Model>> | undefined): Provider.Model {
// The 1M context version's price tiers are on the base model.
const id = (model.resolvedModel ?? model.id).replace(/\[1m\]$/, "")
// Mantle names models `anthropic.claude-…`, and Vertex lists current models as `claude-…@default`.
const priced = prices?.[id] ?? prices?.[id.replace(/^anthropic\./, "")] ?? prices?.[`${id}@default`]
return {
id: ModelID.make(model.id),
providerID: ProviderID.make(ClaudeAgentID.ID),
api: { id: model.id, url: "", npm: ClaudeAgentID.PACKAGE },
name: model.name,
family: model.id.includes("haiku") ? "claude-haiku" : undefined,
capabilities: {
// Claude Code sets sampling itself; temperature, top-p and top-k are not passed through.
temperature: false,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: priced?.cost ?? { input: 0, output: 0, cache: { read: 0, write: 0 } },
// The SDK reports context windows before a prompt, but not output limits.
limit: { context: model.contextWindow ?? DEFAULT_CONTEXT, output: priced?.limit.output ?? DEFAULT_OUTPUT },
status: "active",
options: {},
headers: {},
release_date: "",
variants: Object.fromEntries(model.efforts.map((effort) => [effort, { effort }])),
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
export * as ClaudeFork from "./fork"

import { ClaudeSDK } from "./sdk"

export interface Result {
readonly sessionID: string
readonly messages: ReadonlyMap<string, string>
}

// The SDK gives forked messages fresh UUIDs. Verify the copied prefix before mapping checkpoints onto them.
export async function create(sessionID: string, messageID: string): Promise<Result | undefined> {
const { deleteSession, forkSession, getSessionMessages } = await ClaudeSDK.load()
const source = await getSessionMessages(sessionID)
const end = source.findIndex((message) => message.uuid === messageID)
if (end < 0) return
const fork = await forkSession(sessionID, { upToMessageId: messageID })
try {
const copied = await getSessionMessages(fork.sessionId)
if (copied.length !== end + 1) throw new Error("Claude fork did not preserve the requested history boundary")
const messages = new Map<string, string>()
for (let i = 0; i < copied.length; i++) {
const before = source[i]!
const after = copied[i]!
if (before.type !== after.type || JSON.stringify(before.message) !== JSON.stringify(after.message)) {
throw new Error("Claude fork changed the copied conversation")
}
messages.set(before.uuid, after.uuid)
}
return { sessionID: fork.sessionId, messages }
} catch (error) {
await deleteSession(fork.sessionId)
throw error
}
}
Loading
Loading