summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/llm
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-24 13:24:04 +0900
committerAdam Malczewski <[email protected]>2026-05-24 13:24:04 +0900
commit399e1509b93b9f3c56142f94b8fb2c30c2dedb2f (patch)
treed67f18f5cca91a66e3146cbd2f48920571768e23 /packages/core/src/llm
parent997b00034435440d412f955e05e53f09bae83f9e (diff)
downloaddispatch-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/llm')
-rw-r--r--packages/core/src/llm/provider.ts94
1 files changed, 86 insertions, 8 deletions
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 };