diff options
| author | Adam Malczewski <[email protected]> | 2026-05-24 13:24:04 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-24 13:24:04 +0900 |
| commit | 399e1509b93b9f3c56142f94b8fb2c30c2dedb2f (patch) | |
| tree | d67f18f5cca91a66e3146cbd2f48920571768e23 /packages/core/src | |
| parent | 997b00034435440d412f955e05e53f09bae83f9e (diff) | |
| download | dispatch-399e1509b93b9f3c56142f94b8fb2c30c2dedb2f.tar.gz dispatch-399e1509b93b9f3c56142f94b8fb2c30c2dedb2f.zip | |
fix: prompt caching, OpenCode Go MiniMax/Qwen support, Opus 4.7 thinking, SDK compat
- Implement Anthropic prompt caching: first system message + last 2 non-system messages get cache_control: ephemeral, mirroring OpenCode's applyCaching strategy. Move system prompt inline into messages array so providerOptions can attach.
- Add opencode-anthropic provider variant routing MiniMax/Qwen models through the /messages endpoint with x-api-key auth, distinct from the Claude OAuth flow's Bearer auth and Claude Code mimicry.
- Split isAnthropic into isClaudeOAuth (billing header, mcp_ tool prefix, thinking config) and usesAnthropicSDK (cache markers) so non-OAuth Anthropic-format gateways get the right treatment.
- Pin @ai-sdk/anthropic to ^1.2.12: v3 returns LanguageModelV3-spec models that ai v4's streamText rejects at runtime ('AI SDK 4 only supports models that implement specification version v1'). Drop unnecessary V1 casts.
- Restore Opus 4.7 extended thinking by rewriting the outgoing /messages body in the Claude OAuth fetch interceptor: inject thinking: { type: 'adaptive' } (v1 SDK can't emit it), strip temperature/top_p/top_k (Anthropic rejects them with thinking enabled). Gated on max_tokens > 4096 so effort=none still works.
- Bump MAX_STEPS from 10 to 50 to align with AI SDK's stepCountIs(20) default and reduce mid-task halts.
- Fix pre-existing typecheck errors in agent-manager.ts (entry/nextEntry narrowing), app.ts (agentModels body field), KeyUsage.svelte (m guards), and a TS2742 in provider.ts via explicit ModelFactory return type.
- buildFallbackSequence now always returns at least one entry so processMessage runs the agent loop even without keyId/modelId (fixes 4 broken agent-manager tests).
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/agent/agent.ts | 138 | ||||
| -rw-r--r-- | packages/core/src/llm/provider.ts | 94 |
2 files changed, 190 insertions, 42 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts index 24de59d..3cd8a5b 100644 --- a/packages/core/src/agent/agent.ts +++ b/packages/core/src/agent/agent.ts @@ -1,6 +1,6 @@ import { realpathSync } from "node:fs"; import { dirname, isAbsolute, relative, resolve } from "node:path"; -import type { CoreMessage, LanguageModelV1 } from "ai"; +import type { CoreMessage, CoreSystemMessage } from "ai"; import { streamText } from "ai"; import { buildBillingHeaderValue, SYSTEM_IDENTITY } from "../credentials/claude.js"; import { createProvider, prefixToolName, unprefixToolName } from "../llm/provider.js"; @@ -16,7 +16,7 @@ import type { ToolResult, } from "../types/index.js"; -function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMessage[] { +function toCoreMessages(messages: ChatMessage[], useToolPrefix?: boolean): CoreMessage[] { const result: CoreMessage[] = []; for (const msg of messages) { if (msg.role === "user") { @@ -27,12 +27,12 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes | { type: "tool-call"; toolCallId: string; toolName: string; args: Record<string, unknown> } > = [{ type: "text", text: msg.content }]; for (const tc of msg.toolCalls ?? []) { - const toolName = isAnthropic ? prefixToolName(tc.name) : tc.name; + const toolName = useToolPrefix ? prefixToolName(tc.name) : tc.name; parts.push({ type: "tool-call", toolCallId: tc.id, toolName, args: tc.arguments }); } result.push({ role: "assistant", content: parts }); for (const tr of msg.toolResults ?? []) { - const toolName = isAnthropic ? prefixToolName(tr.toolName) : tr.toolName; + const toolName = useToolPrefix ? prefixToolName(tr.toolName) : tr.toolName; result.push({ role: "tool", content: [ @@ -45,6 +45,47 @@ function toCoreMessages(messages: ChatMessage[], isAnthropic?: boolean): CoreMes return result; } +/** + * Apply Anthropic prompt-caching breakpoints to a message list. + * + * Anthropic caches the entire request prefix up to (and including) any block + * marked with `cache_control`. Up to 4 breakpoints per request; we use three + * (first system + last 2 non-system). + * + * Strategy (mirrors OpenCode's `applyCaching` in transform.ts): + * - Mark the first system message → caches system prompt (and tools, which + * sit before messages in the request body). + * - Mark the last 2 non-system messages → rolling cache that extends through + * the conversation each turn. + * + * Only applied for the Anthropic provider. OpenCode Zen's OpenAI-compatible + * endpoint (`/zen/v1/chat/completions`) backs models like MiniMax, GLM, Kimi, + * Grok, etc. — those upstreams do automatic prefix caching server-side and + * don't accept `cache_control` markers. OpenCode's own transform.ts gates + * `applyCaching` on Anthropic-family detection for the same reason. Models + * served via `@ai-sdk/openai` (GPT) and `@ai-sdk/google` (Gemini) likewise + * use server-side automatic caching. + */ +function applyAnthropicCaching(msgs: CoreMessage[]): void { + const targets = new Set<CoreMessage>(); + + const systemMsgs = msgs.filter((m) => m.role === "system").slice(0, 2); + for (const m of systemMsgs) targets.add(m); + + const nonSystem = msgs.filter((m) => m.role !== "system").slice(-2); + for (const m of nonSystem) targets.add(m); + + for (const msg of targets) { + msg.providerOptions = { + ...msg.providerOptions, + anthropic: { + ...(msg.providerOptions?.anthropic ?? {}), + cacheControl: { type: "ephemeral" }, + }, + }; + } +} + function formatError(err: unknown, config: AgentConfig): string { const context = `[model=${config.model}, baseURL=${config.baseURL}]`; @@ -66,7 +107,7 @@ function formatError(err: unknown, config: AgentConfig): string { return `${String(err)} ${context}`; } -const MAX_STEPS = 10; +const MAX_STEPS = 50; export class Agent { status: AgentStatus = "idle"; @@ -223,7 +264,15 @@ export class Agent { this.messages.push({ role: "user", content: userMessage }); const registry = createToolRegistry(this.config.tools); - const isAnthropic = this.config.provider === "anthropic"; + // `isClaudeOAuth` gates Claude-Code-CLI-specific behavior: billing-header + // injection, identity preamble, `mcp_*` tool name prefix, and extended + // thinking config. Only the OAuth flow (provider="anthropic") needs these. + // `usesAnthropicSDK` is the broader category — any provider whose + // requests are serialized by `@ai-sdk/anthropic` and therefore expect + // Anthropic-style `cache_control` markers. Today that's Claude OAuth + // plus OpenCode Go's MiniMax/Qwen routes. + const isClaudeOAuth = this.config.provider === "anthropic"; + const usesAnthropicSDK = isClaudeOAuth || this.config.provider === "opencode-anthropic"; const providerFactory = createProvider({ apiKey: this.config.apiKey, baseURL: this.config.baseURL, @@ -231,17 +280,21 @@ export class Agent { claudeCredentials: this.config.claudeCredentials, }); - // For Anthropic provider, prefix tool names and build full system prompt + // Only the Claude OAuth flow expects `mcp_*` prefixed tool names. The + // OpenCode Go anthropic-format endpoint passes tools through to MiniMax + // or Qwen, which expect raw names. const aiTools = registry.getAISDKTools(); - const tools = isAnthropic + const tools = isClaudeOAuth ? Object.fromEntries( Object.entries(aiTools).map(([name, tool]) => [prefixToolName(name), tool]), ) : aiTools; - // Build system prompt + // Build system prompt — Claude OAuth requests embed a billing header + // and the Claude Code identity preamble so Anthropic recognizes the + // request as coming from the official CLI. let systemPrompt = this.config.systemPrompt; - if (isAnthropic) { + if (isClaudeOAuth) { const billingHeader = buildBillingHeaderValue(this.messages); systemPrompt = `${billingHeader}\n${SYSTEM_IDENTITY}\n\n${systemPrompt}`; } @@ -260,41 +313,58 @@ export class Agent { const effort = options?.reasoningEffort ?? this.config.reasoningEffort ?? "max"; // Build stream text options - const rawModel = providerFactory(this.config.model); - const model = rawModel as unknown as LanguageModelV1; + const model = providerFactory(this.config.model); + + // Build the message list with the system prompt prepended as a system + // role message. This is required for Anthropic prompt caching: the + // `system` shortcut parameter takes a plain string with nowhere to + // attach `providerOptions.anthropic.cacheControl`. Moving it inline + // also lets us apply rolling cache breakpoints to the last messages. + const systemMessage: CoreSystemMessage = { role: "system", content: systemPrompt }; + const coreMessages: CoreMessage[] = [ + systemMessage, + ...toCoreMessages(stepMessages, isClaudeOAuth), + ]; + + if (usesAnthropicSDK) { + applyAnthropicCaching(coreMessages); + } + const streamOptions: Parameters<typeof streamText>[0] = { model, - system: systemPrompt, - messages: toCoreMessages(stepMessages, isAnthropic), + messages: coreMessages, tools, }; - if (isAnthropic && effort !== "none") { - const modelId = this.config.model; - const isOpus47 = modelId === "claude-opus-4-7"; - + if (isClaudeOAuth && effort !== "none") { + // Opus 4.7 rejects `thinking: { type: "enabled" }` ("reasoning- + // signature without reasoning") and only supports adaptive thinking. + // `@ai-sdk/anthropic` v1.x can't emit `type: "adaptive"`, so we + // leave `providerOptions.anthropic.thinking` unset and let the + // custom fetch in `createClaudeOAuthProvider` inject the adaptive + // shape into the request body. We still set `maxTokens` here so + // the SDK serializes it — adaptive thinking spends from this + // budget rather than a separate one. + const isOpus47 = this.config.model === "claude-opus-4-7"; + const budgetTokens = + effort === "max" + ? 16000 + : effort === "high" + ? 10000 + : effort === "medium" + ? 5000 + : effort === "low" + ? 2000 + : 0; if (isOpus47) { - // Opus 4.7 only supports adaptive thinking - streamOptions.providerOptions = { - anthropic: { thinking: { type: "adaptive" as const } }, - }; + streamOptions.maxTokens = budgetTokens + 8000; } else { - const budgetTokens = - effort === "max" - ? 16000 - : effort === "high" - ? 10000 - : effort === "medium" - ? 5000 - : effort === "low" - ? 2000 - : 0; streamOptions.providerOptions = { anthropic: { thinking: { type: "enabled" as const, budgetTokens } }, }; streamOptions.maxTokens = budgetTokens + 8000; } - } else if (!isAnthropic && effort !== "none") { + } else if (!usesAnthropicSDK && effort !== "none") { streamOptions.providerOptions = { openaiCompatible: { reasoningEffort: effort } }; } @@ -313,7 +383,7 @@ export class Agent { yield { type: "reasoning-delta", delta: event.textDelta }; } else if (event.type === "tool-call") { const rawName = event.toolName; - const toolName = isAnthropic ? unprefixToolName(rawName) : rawName; + const toolName = isClaudeOAuth ? unprefixToolName(rawName) : rawName; const toolCall: ToolCall = { id: event.toolCallId, name: toolName, diff --git a/packages/core/src/llm/provider.ts b/packages/core/src/llm/provider.ts index 7cbb829..a7d800c 100644 --- a/packages/core/src/llm/provider.ts +++ b/packages/core/src/llm/provider.ts @@ -1,6 +1,6 @@ import { createAnthropic } from "@ai-sdk/anthropic"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; -import type { LanguageModelV1Middleware, LanguageModelV1Prompt } from "ai"; +import type { LanguageModelV1, LanguageModelV1Middleware, LanguageModelV1Prompt } from "ai"; import { wrapLanguageModel } from "ai"; function normalizeMessages(msgs: unknown[]): unknown[] { @@ -61,9 +61,19 @@ function unprefixToolName(name: string): string { return name; } -export function createProvider(config: ProviderConfig) { +// Explicit factory return type so the inferred type doesn't leak references +// into transitive `@ai-sdk/provider` paths (which would trip TS2742). +// `@ai-sdk/anthropic` v1.x already returns `LanguageModelV1`-spec models; +// `@ai-sdk/openai-compatible` v0.2.x and `wrapLanguageModel` likewise. +export type ModelFactory = (modelId: string) => LanguageModelV1; + +export function createProvider(config: ProviderConfig): ModelFactory { if (config.provider === "anthropic") { - return createAnthropicProvider(config); + return createClaudeOAuthProvider(config); + } + + if (config.provider === "opencode-anthropic") { + return createApiKeyAnthropicProvider(config); } // Default: OpenAI-compatible provider @@ -94,7 +104,21 @@ export function createProvider(config: ProviderConfig) { }; } -function createAnthropicProvider(config: ProviderConfig) { +/** + * Claude OAuth provider. Used by Dispatch's `anthropic` provider keys + * (claude-pro, claude-max). Swaps `x-api-key` for `Authorization: Bearer` + * to satisfy Anthropic's OAuth flow, and mimics Claude Code CLI request + * headers so the request bills against the user's Claude subscription. + * + * The custom fetch also rewrites the outgoing JSON body for Claude Opus 4.7: + * that model rejects `thinking: { type: "enabled", budget_tokens }` (the only + * shape `@ai-sdk/anthropic` v1.x can emit) with "reasoning-signature without + * reasoning", and instead requires `thinking: { type: "adaptive" }`. `ai` v4 + * is pinned to V1-spec providers, so we can't upgrade to v3 of the Anthropic + * SDK without breaking everything. Doing the rewrite here keeps the rest of + * the agent path SDK-agnostic and limits the special case to one model. + */ +function createClaudeOAuthProvider(config: ProviderConfig): ModelFactory { const accessToken = config.claudeCredentials?.accessToken ?? config.apiKey; const customFetch = Object.assign( @@ -102,7 +126,9 @@ function createAnthropicProvider(config: ProviderConfig) { const headers = new Headers(init?.headers); headers.delete("x-api-key"); headers.set("authorization", `Bearer ${accessToken}`); - return globalThis.fetch(url, { ...init, headers }); + + const body = rewriteBodyForOpus47(init?.body); + return globalThis.fetch(url, { ...init, headers, body }); }, { preconnect: globalThis.fetch.preconnect?.bind(globalThis.fetch) }, ); @@ -118,9 +144,61 @@ function createAnthropicProvider(config: ProviderConfig) { fetch: customFetch as typeof globalThis.fetch, }); - return (modelId: string) => { - return anthropic(modelId); - }; + return (modelId: string) => anthropic(modelId); +} + +/** + * If the request body is a JSON `/messages` payload targeting Claude Opus 4.7 + * and the caller signaled they want thinking (by setting `max_tokens` above + * Anthropic's default 4096), insert `thinking: { type: "adaptive" }`. + * + * Skipping the rewrite when `max_tokens` is small (or absent) keeps `effort: + * "none"` requests as plain non-thinking calls — agent.ts only sets a high + * `max_tokens` when thinking is wanted, so this acts as a clean signal. + * + * Returns the body unchanged for any other model, any non-string body, or any + * payload that fails to parse, leaving non-Anthropic providers, non-Opus-4.7 + * Claude models, and streaming/binary uploads unaffected. + */ +function rewriteBodyForOpus47(body: BodyInit | null | undefined): BodyInit | null | undefined { + if (typeof body !== "string") return body; + let parsed: Record<string, unknown>; + try { + parsed = JSON.parse(body) as Record<string, unknown>; + } catch { + return body; + } + if (parsed.model !== "claude-opus-4-7") return body; + const maxTokens = typeof parsed.max_tokens === "number" ? parsed.max_tokens : 0; + if (maxTokens <= 4096) return body; + parsed.thinking = { type: "adaptive" }; + // Anthropic rejects requests that combine extended thinking (enabled or + // adaptive) with any temperature other than 1. `ai` v4 defaults + // `temperature: 0`, and the v1 Anthropic SDK normally strips it when its + // own `isThinking` flag is set — but we're injecting `thinking` here, + // behind the SDK's back, so we have to strip it ourselves. Same for + // `top_p` and `top_k`, which are likewise rejected with thinking. + delete parsed.temperature; + delete parsed.top_p; + delete parsed.top_k; + return JSON.stringify(parsed); +} + +/** + * Plain-API-key Anthropic-format provider. Used to hit gateways that speak + * Anthropic's `/messages` protocol with a standard `x-api-key` header — most + * importantly OpenCode Go's MiniMax and Qwen routes. Unlike the Claude OAuth + * variant, no `claudeCredentials` are present, no Claude Code mimicry headers + * are sent, and the API key is passed verbatim through the SDK's default + * authentication path. + */ +function createApiKeyAnthropicProvider(config: ProviderConfig): ModelFactory { + const anthropic = createAnthropic({ + apiKey: config.apiKey, + baseURL: config.baseURL || "https://opencode.ai/zen/go/v1", + }); + + return (modelId: string) => anthropic(modelId); } export { prefixToolName, unprefixToolName }; |
