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
354 changes: 354 additions & 0 deletions packages/core/lib/v3/llm/ClaudeCodeClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,354 @@
import {
CoreAssistantMessage,
ModelMessage,
CoreSystemMessage,
CoreUserMessage,
generateObject,
generateText,
ImagePart,
NoObjectGeneratedError,
TextPart,
ToolSet,
Tool,
} from "ai";
import type { LanguageModelV2 } from "@ai-sdk/provider";
import { LogLine } from "../types/public/logs";
import { AvailableModel, ClientOptions } from "../types/public/model";
import {
CreateChatCompletionOptions,
LLMClient,
LLMResponse,
} from "./LLMClient";

// Type for claude-code model names
export type ClaudeCodeModelName =
| "claude-code-opus"
| "claude-code-sonnet"
| "claude-code-haiku";

// Map from Stagehand model names to claude-code provider model names
const claudeCodeModelMap: Record<ClaudeCodeModelName, string> = {
"claude-code-opus": "opus",
"claude-code-sonnet": "sonnet",
"claude-code-haiku": "haiku",
};

export class ClaudeCodeClient extends LLMClient {
public type = "claude-code" as const;
public hasVision = true;
private model: LanguageModelV2;
private logger?: (message: LogLine) => void;

constructor({
modelName,
logger,
clientOptions,
}: {
modelName: ClaudeCodeModelName;
logger?: (message: LogLine) => void;
clientOptions?: ClientOptions;
}) {
super(modelName as AvailableModel);
this.logger = logger;
this.clientOptions = clientOptions;

// Dynamically import the claude-code provider
// This is done lazily to avoid requiring the package if not used
const providerModelName = claudeCodeModelMap[modelName];
this.model = this.createClaudeCodeModel(providerModelName);
Comment on lines +55 to +58
Copy link
Contributor

Choose a reason for hiding this comment

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

Inaccurate "lazy loading" comment

The comment on line 56 says "This is done lazily to avoid requiring the package if not used", but createClaudeCodeModel is called synchronously on line 58 — directly in the constructor body. The require() call executes eagerly at construction time (i.e., when LLMProvider.getClient() instantiates the client), not lazily when the model is first used.

Consider either: (a) updating the comment to accurately describe eager initialization, or (b) actually deferring the require() to the first createChatCompletion call if lazy loading is truly desired.

}

private createClaudeCodeModel(modelName: string): LanguageModelV2 {
// Dynamic require to handle optional dependency
// eslint-disable-next-line @typescript-eslint/no-var-requires
let claudeCode: (modelName: string) => LanguageModelV2;
try {
// Try to import the claude-code provider
const provider = require("ai-sdk-provider-claude-code");
claudeCode = provider.claudeCode;
} catch {
throw new Error(
"ai-sdk-provider-claude-code package is not installed. " +
"Please install it with: npm install ai-sdk-provider-claude-code\n" +
"Also ensure Claude Code CLI is installed and authenticated via 'claude login'.",
);
}
return claudeCode(modelName);
}

public getLanguageModel(): LanguageModelV2 {
return this.model;
}

async createChatCompletion<T = LLMResponse>({
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 9, 2026

Choose a reason for hiding this comment

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

P2: The logger parameter from CreateChatCompletionOptions is not destructured, so all per-request logging uses this.logger instead. Every other LLM client in this codebase extracts and uses the per-request logger, which supports request-scoped tracing. Destructure logger and use it (with a fallback to this.logger) for consistency.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/lib/v3/llm/ClaudeCodeClient.ts, line 83:

<comment>The `logger` parameter from `CreateChatCompletionOptions` is not destructured, so all per-request logging uses `this.logger` instead. Every other LLM client in this codebase extracts and uses the per-request `logger`, which supports request-scoped tracing. Destructure `logger` and use it (with a fallback to `this.logger`) for consistency.</comment>

<file context>
@@ -0,0 +1,354 @@
+    return this.model;
+  }
+
+  async createChatCompletion<T = LLMResponse>({
+    options,
+  }: CreateChatCompletionOptions): Promise<T> {
</file context>
Fix with Cubic

options,
}: CreateChatCompletionOptions): Promise<T> {
Comment on lines +83 to +85
Copy link
Contributor

Choose a reason for hiding this comment

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

logger parameter from CreateChatCompletionOptions is ignored

createChatCompletion destructures only { options } from CreateChatCompletionOptions, silently dropping the logger argument that callers pass with each request. Every other client in this codebase (AnthropicClient, OpenAIClient, GroqClient, CerebrasClient, GoogleClient) uses the logger from the method parameters — not this.logger — for all per-request log lines. If a caller passes a request-scoped logger (e.g., for tracing), it will be completely ignored here.

Suggested change
async createChatCompletion<T = LLMResponse>({
options,
}: CreateChatCompletionOptions): Promise<T> {
async createChatCompletion<T = LLMResponse>({
options,
logger,
}: CreateChatCompletionOptions): Promise<T> {

Then replace each this.logger?.({ with (logger ?? this.logger)?.({ (or use the passed logger directly as other clients do).

this.logger?.({
category: "claude-code",
message: "creating chat completion",
level: 2,
auxiliary: {
options: {
value: JSON.stringify({
...options,
image: undefined,
messages: options.messages.map((msg) => ({
...msg,
content: Array.isArray(msg.content)
? msg.content.map((c) =>
"image_url" in c
? { ...c, image_url: { url: "[IMAGE_REDACTED]" } }
: c,
)
: msg.content,
})),
}),
type: "object",
},
modelName: {
value: this.modelName,
type: "string",
},
},
});

const formattedMessages: ModelMessage[] = options.messages.map(
(message) => {
if (Array.isArray(message.content)) {
if (message.role === "system") {
const systemMessage: CoreSystemMessage = {
role: "system",
content: message.content
.map((c) => ("text" in c ? c.text : ""))
.join("\n"),
};
return systemMessage;
}

const contentParts = message.content.map((content) => {
if ("image_url" in content) {
const imageContent: ImagePart = {
type: "image",
image: content.image_url.url,
};
return imageContent;
} else {
const textContent: TextPart = {
type: "text",
text: content.text,
};
return textContent;
}
});

if (message.role === "user") {
const userMessage: CoreUserMessage = {
role: "user",
content: contentParts,
};
return userMessage;
} else {
const textOnlyParts = contentParts.map((part) => ({
type: "text" as const,
text: part.type === "image" ? "[Image]" : part.text,
}));
const assistantMessage: CoreAssistantMessage = {
role: "assistant",
content: textOnlyParts,
};
return assistantMessage;
}
}

return {
role: message.role,
content: message.content,
};
},
);

// Add image to messages if provided
if (options.image) {
formattedMessages.push({
role: "user",
content: [
{
type: "image",
image: options.image.buffer.toString("base64"),
} as ImagePart,
...(options.image.description
? [{ type: "text" as const, text: options.image.description }]
: []),
],
});
}

let objectResponse: Awaited<ReturnType<typeof generateObject>>;
if (options.response_model) {
try {
objectResponse = await generateObject({
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 9, 2026

Choose a reason for hiding this comment

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

P2: options.maxOutputTokens is never forwarded to the generateObject call. Other LLM clients in this codebase (e.g., AnthropicClient, GoogleClient) all forward this option, so callers setting a token limit will have it silently ignored for Claude Code models. Add maxOutputTokens: options.maxOutputTokens to the generateObject call.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/lib/v3/llm/ClaudeCodeClient.ts, line 189:

<comment>`options.maxOutputTokens` is never forwarded to the `generateObject` call. Other LLM clients in this codebase (e.g., `AnthropicClient`, `GoogleClient`) all forward this option, so callers setting a token limit will have it silently ignored for Claude Code models. Add `maxOutputTokens: options.maxOutputTokens` to the `generateObject` call.</comment>

<file context>
@@ -0,0 +1,354 @@
+    let objectResponse: Awaited<ReturnType<typeof generateObject>>;
+    if (options.response_model) {
+      try {
+        objectResponse = await generateObject({
+          model: this.model,
+          messages: formattedMessages,
</file context>
Fix with Cubic

model: this.model,
messages: formattedMessages,
schema: options.response_model.schema,
temperature: options.temperature,
});
Comment on lines +189 to +194
Copy link
Contributor

Choose a reason for hiding this comment

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

maxOutputTokens silently ignored for generateObject

options.maxOutputTokens is present in ChatCompletionOptions and is respected by every other client (AnthropicClient passes it as max_tokens: options.maxOutputTokens || 8192, GroqClient and CerebrasClient also pass max_tokens, GoogleClient passes maxOutputTokens). Here it is never forwarded to generateObject, so any caller that sets maxOutputTokens on a Claude Code model will have that constraint silently dropped — potentially leading to unexpectedly long or truncated responses.

Suggested change
objectResponse = await generateObject({
model: this.model,
messages: formattedMessages,
schema: options.response_model.schema,
temperature: options.temperature,
});
objectResponse = await generateObject({
model: this.model,
messages: formattedMessages,
schema: options.response_model.schema,
temperature: options.temperature,
maxTokens: options.maxOutputTokens,
});

} catch (err) {
if (NoObjectGeneratedError.isInstance(err)) {
this.logger?.({
category: "claude-code",
message: `error: ${err.message}`,
level: 0,
auxiliary: {
cause: {
value: JSON.stringify(err.cause ?? {}),
type: "object",
},
text: {
value: err.text ?? "",
type: "string",
},
response: {
value: JSON.stringify(err.response ?? {}),
type: "object",
},
usage: {
value: JSON.stringify(err.usage ?? {}),
type: "object",
},
finishReason: {
value: err.finishReason ?? "unknown",
type: "string",
},
requestId: {
value: options.requestId,
type: "string",
},
},
});

throw err;
}
throw err;
}

const result = {
data: objectResponse.object,
usage: {
prompt_tokens: objectResponse.usage.inputTokens ?? 0,
completion_tokens: objectResponse.usage.outputTokens ?? 0,
reasoning_tokens: objectResponse.usage.reasoningTokens ?? 0,
cached_input_tokens: objectResponse.usage.cachedInputTokens ?? 0,
total_tokens: objectResponse.usage.totalTokens ?? 0,
},
} as T;

this.logger?.({
category: "claude-code",
message: "response",
level: 1,
auxiliary: {
response: {
value: JSON.stringify({
object: objectResponse.object,
usage: objectResponse.usage,
finishReason: objectResponse.finishReason,
}),
type: "object",
},
requestId: {
value: options.requestId,
type: "string",
},
},
});

return result;
}

const tools: ToolSet = {};
if (options.tools && options.tools.length > 0) {
for (const tool of options.tools) {
tools[tool.name] = {
description: tool.description,
inputSchema: tool.parameters,
} as Tool;
}
}

const textResponse = await generateText({
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 9, 2026

Choose a reason for hiding this comment

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

P2: options.maxOutputTokens is never forwarded to the generateText call. This means the token limit option is silently dropped for all Claude Code text generation calls. Add maxOutputTokens: options.maxOutputTokens to the generateText call.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/lib/v3/llm/ClaudeCodeClient.ts, line 278:

<comment>`options.maxOutputTokens` is never forwarded to the `generateText` call. This means the token limit option is silently dropped for all Claude Code text generation calls. Add `maxOutputTokens: options.maxOutputTokens` to the `generateText` call.</comment>

<file context>
@@ -0,0 +1,354 @@
+      }
+    }
+
+    const textResponse = await generateText({
+      model: this.model,
+      messages: formattedMessages,
</file context>
Fix with Cubic

model: this.model,
messages: formattedMessages,
tools: Object.keys(tools).length > 0 ? tools : undefined,
toolChoice:
Object.keys(tools).length > 0
? options.tool_choice === "required"
? "required"
: options.tool_choice === "none"
? "none"
: "auto"
: undefined,
temperature: options.temperature,
});
Comment on lines +278 to +291
Copy link
Contributor

Choose a reason for hiding this comment

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

maxOutputTokens silently ignored for generateText

Same issue as the generateObject path above — options.maxOutputTokens is never forwarded to generateText. This means the token limit option is consistently dropped for all Claude Code calls.

Suggested change
const textResponse = await generateText({
model: this.model,
messages: formattedMessages,
tools: Object.keys(tools).length > 0 ? tools : undefined,
toolChoice:
Object.keys(tools).length > 0
? options.tool_choice === "required"
? "required"
: options.tool_choice === "none"
? "none"
: "auto"
: undefined,
temperature: options.temperature,
});
const textResponse = await generateText({
model: this.model,
messages: formattedMessages,
tools: Object.keys(tools).length > 0 ? tools : undefined,
toolChoice:
Object.keys(tools).length > 0
? options.tool_choice === "required"
? "required"
: options.tool_choice === "none"
? "none"
: "auto"
: undefined,
temperature: options.temperature,
maxTokens: options.maxOutputTokens,
});


// Transform AI SDK response to match LLMResponse format
const transformedToolCalls = (textResponse.toolCalls || []).map(
(toolCall) => ({
id:
toolCall.toolCallId ||
`call_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: "function",
function: {
name: toolCall.toolName,
arguments: JSON.stringify(toolCall.input),
},
}),
);

const result = {
id: `chatcmpl_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
object: "chat.completion",
created: Math.floor(Date.now() / 1000),
model: this.modelName,
choices: [
{
index: 0,
message: {
role: "assistant",
content: textResponse.text || null,
tool_calls: transformedToolCalls,
},
finish_reason: textResponse.finishReason || "stop",
},
],
usage: {
prompt_tokens: textResponse.usage.inputTokens ?? 0,
completion_tokens: textResponse.usage.outputTokens ?? 0,
reasoning_tokens: textResponse.usage.reasoningTokens ?? 0,
cached_input_tokens: textResponse.usage.cachedInputTokens ?? 0,
total_tokens: textResponse.usage.totalTokens ?? 0,
},
} as T;

this.logger?.({
category: "claude-code",
message: "response",
level: 2,
auxiliary: {
response: {
value: JSON.stringify({
text: textResponse.text,
usage: textResponse.usage,
finishReason: textResponse.finishReason,
}),
type: "object",
},
requestId: {
value: options.requestId,
type: "string",
},
},
});

return result;
}
}
13 changes: 13 additions & 0 deletions packages/core/lib/v3/llm/LLMProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
import { AISdkClient } from "./aisdk";
import { AnthropicClient } from "./AnthropicClient";
import { CerebrasClient } from "./CerebrasClient";
import {
ClaudeCodeClient,
ClaudeCodeModelName,
} from "./ClaudeCodeClient";
import { GoogleClient } from "./GoogleClient";
import { GroqClient } from "./GroqClient";
import { LLMClient } from "./LLMClient";
Expand Down Expand Up @@ -79,6 +83,9 @@ const modelToProviderMap: { [key in AvailableModel]: ModelProvider } = {
"claude-3-5-sonnet-20241022": "anthropic",
"claude-3-7-sonnet-20250219": "anthropic",
"claude-3-7-sonnet-latest": "anthropic",
"claude-code-opus": "claude-code",
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Mar 9, 2026

Choose a reason for hiding this comment

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

P1: Custom agent: Ensure we never check against hardcoded lists of allowed LLM model names

Adding three new hardcoded model names (claude-code-opus, claude-code-sonnet, claude-code-haiku) to modelToProviderMap violates the rule against hardcoded allowed-model lists. Models change frequently and each addition requires updating multiple files (model.ts, LLMProvider.ts, ClaudeCodeClient.ts).

Consider supporting these models through the existing provider/model-name format (e.g. claude-code/opus) which already works via the dynamic modelName.includes("/") branch — that way new Claude Code model variants work automatically without code changes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/core/lib/v3/llm/LLMProvider.ts, line 86:

<comment>Adding three new hardcoded model names (`claude-code-opus`, `claude-code-sonnet`, `claude-code-haiku`) to `modelToProviderMap` violates the rule against hardcoded allowed-model lists. Models change frequently and each addition requires updating multiple files (`model.ts`, `LLMProvider.ts`, `ClaudeCodeClient.ts`).

Consider supporting these models through the existing `provider/model-name` format (e.g. `claude-code/opus`) which already works via the dynamic `modelName.includes("/")` branch — that way new Claude Code model variants work automatically without code changes.</comment>

<file context>
@@ -79,6 +83,9 @@ const modelToProviderMap: { [key in AvailableModel]: ModelProvider } = {
   "claude-3-5-sonnet-20241022": "anthropic",
   "claude-3-7-sonnet-20250219": "anthropic",
   "claude-3-7-sonnet-latest": "anthropic",
+  "claude-code-opus": "claude-code",
+  "claude-code-sonnet": "claude-code",
+  "claude-code-haiku": "claude-code",
</file context>
Fix with Cubic

"claude-code-sonnet": "claude-code",
"claude-code-haiku": "claude-code",
"cerebras-llama-3.3-70b": "cerebras",
"cerebras-llama-3.1-8b": "cerebras",
"groq-llama-3.3-70b-versatile": "groq",
Expand Down Expand Up @@ -192,6 +199,12 @@ export class LLMProvider {
modelName: availableModel,
clientOptions,
});
case "claude-code":
return new ClaudeCodeClient({
logger: this.logger,
modelName: availableModel as ClaudeCodeModelName,
clientOptions,
});
default:
throw new UnsupportedModelProviderError([
...new Set(Object.values(modelToProviderMap)),
Expand Down
Loading