diff --git a/agent_got_talent_be/.env.example b/agent_got_talent_be/.env.example index 1deb56d..458494a 100644 --- a/agent_got_talent_be/.env.example +++ b/agent_got_talent_be/.env.example @@ -1,3 +1,19 @@ +# --- LLM provider selection --- +# Set USE_CLAUDE_GATEWAY=true to route every agent through the +# self-hosted claude-internal-gateway (Anthropic Claude under the hood). +# Set to "false" or leave unset to keep using Gemini directly. +# +# This flag is read by src/agent/llmFactory.ts and is the single +# source of truth for which provider all agents use. +USE_CLAUDE_GATEWAY=false + +# Required when USE_CLAUDE_GATEWAY=true. Ask Manash for the token; +# do NOT commit it. +OAK_GATEWAY_URL="https://tunnel.cfsprotocol.com" +OAK_PROXY_TOKEN="ask-manash-for-this" + +# Required when USE_CLAUDE_GATEWAY=false (default). Used directly +# by @google/adk's Gemini class. GEMINI_API_KEY="YOUR_GEMINI_API_KEY" TURSO_DATABASE_URL="libsql://your-db.turso.io" diff --git a/agent_got_talent_be/scripts/try_oak_claude.ts b/agent_got_talent_be/scripts/try_oak_claude.ts new file mode 100644 index 0000000..fd7b121 --- /dev/null +++ b/agent_got_talent_be/scripts/try_oak_claude.ts @@ -0,0 +1,103 @@ +/** + * Trial script: verifies OakClaude works inside agent-got-talent's actual + * environment, against the locally-running claude-internal-gateway, with + * a real ADK tool. + * + * This file lives at scripts/try_oak_claude.ts and is meant to be deleted + * after the migration is validated. It does not touch any production + * agent file or get committed. + * + * Run with: + * + * OAK_PROXY_TOKEN= OAK_GATEWAY_URL=http://127.0.0.1:8765 \ + * npx tsx scripts/try_oak_claude.ts + */ + +import { InMemoryRunner, LlmAgent } from "@google/adk"; +import { OakClaude } from "../src/agent/oak_claude_llm.js"; +import { getCurrentTime } from "../src/agent/tools/time.js"; + +const GATEWAY_URL = process.env.OAK_GATEWAY_URL ?? "http://127.0.0.1:8765"; +const APP_NAME = "try_oak_claude"; + +async function main() { + if (!process.env.OAK_PROXY_TOKEN) { + console.error("FAIL: OAK_PROXY_TOKEN env var not set"); + process.exit(2); + } + + console.log("=== STAGE A: minimal LlmAgent + 1 tool via OakClaude ==="); + const agent = new LlmAgent({ + name: "time_assistant", + model: new OakClaude({ + model: "claude-opus-4-6", + gatewayUrl: GATEWAY_URL, + proxyToken: process.env.OAK_PROXY_TOKEN, + }), + description: "An assistant that knows the current time.", + instruction: + "You are a helpful assistant. When the user asks about time, " + + "use the get_current_time tool to fetch the current time and " + + "answer with the result.", + tools: [getCurrentTime], + }); + + const runner = new InMemoryRunner({ agent, appName: APP_NAME }); + const session = await runner.sessionService.createSession({ + appName: APP_NAME, + userId: "tester", + }); + + const events = runner.runAsync({ + userId: session.userId, + sessionId: session.id, + newMessage: { + role: "user", + parts: [{ text: "What is the current time? Answer briefly." }], + }, + }); + + let toolCalled = false; + let finalText = ""; + for await (const event of events) { + const parts = event.content?.parts ?? []; + for (const part of parts) { + if (part.functionCall) { + toolCalled = true; + console.log( + `[tool_call] name=${part.functionCall.name} args=${JSON.stringify(part.functionCall.args)}`, + ); + } + if (part.functionResponse) { + console.log( + `[tool_result] name=${part.functionResponse.name} response=${JSON.stringify(part.functionResponse.response)}`, + ); + } + if (part.text && !("thought" in part && part.thought)) { + finalText += part.text; + } + } + } + + console.log(`[final_text] ${finalText}`); + + if (!toolCalled) { + console.error( + "FAIL: agent did not call get_current_time. The tool round-trip is broken.", + ); + process.exit(1); + } + if (!finalText.trim()) { + console.error("FAIL: agent produced no final text answer."); + process.exit(1); + } + console.log("STAGE A: ok (tool called, final answer produced)"); + console.log("\nALL OK"); +} + +main().catch((e: unknown) => { + const err = e as Error; + console.error(`FAIL: ${err.constructor.name}: ${err.message}`); + if (err.stack) console.error(err.stack); + process.exit(1); +}); diff --git a/agent_got_talent_be/src/agent/agents/backerAgent.ts b/agent_got_talent_be/src/agent/agents/backerAgent.ts index 98dc257..36aa146 100644 --- a/agent_got_talent_be/src/agent/agents/backerAgent.ts +++ b/agent_got_talent_be/src/agent/agents/backerAgent.ts @@ -1,4 +1,5 @@ import { LlmAgent } from "@google/adk"; +import { getModel } from "../llmFactory.js"; import { PLATFORM_CONTEXT, BRAINROT_PERSONALITY } from "../prompts/shared.js"; import { listLiveCampaigns, evaluateCampaign, pledgeToCampaign } from "../tools/pledgeTools.js"; import { postComment } from "../tools/commentTools.js"; @@ -6,7 +7,7 @@ import { listAgents } from "../tools/platformTools.js"; export const backerAgent = new LlmAgent({ name: "backer", - model: "gemini-2.5-flash", + model: getModel({ gemini: "gemini-2.5-flash", claude: "claude-opus-4-6" }), description: "Evaluates live campaigns, pledges funds to promising ones, and drops hot-take comments to kick off brainrot debate threads.", instruction: `${PLATFORM_CONTEXT} diff --git a/agent_got_talent_be/src/agent/agents/campaignCreatorAgent.ts b/agent_got_talent_be/src/agent/agents/campaignCreatorAgent.ts index b4b3c29..9052760 100644 --- a/agent_got_talent_be/src/agent/agents/campaignCreatorAgent.ts +++ b/agent_got_talent_be/src/agent/agents/campaignCreatorAgent.ts @@ -1,4 +1,5 @@ import { LlmAgent } from "@google/adk"; +import { getModel } from "../llmFactory.js"; import { PLATFORM_CONTEXT, MVP_BOOK_INSTRUCTIONS } from "../prompts/shared.js"; import { analyzeTrendingTopics, checkExistingCampaigns, createCampaign } from "../tools/campaignTools.js"; import { getPublishableCampaigns, publishBook } from "../tools/publicationTools.js"; @@ -6,7 +7,7 @@ import { listAgents } from "../tools/platformTools.js"; export const campaignCreatorAgent = new LlmAgent({ name: "campaign_creator", - model: "gemini-2.5-flash", + model: getModel({ gemini: "gemini-2.5-flash", claude: "claude-opus-4-6" }), description: "Handles the full campaign lifecycle: analyzing trending topics, creating campaigns for high-demand topics, writing MVP-sized books, and publishing them.", instruction: `${PLATFORM_CONTEXT} diff --git a/agent_got_talent_be/src/agent/agents/commentatorAgent.ts b/agent_got_talent_be/src/agent/agents/commentatorAgent.ts index 7f46456..d3bbfbf 100644 --- a/agent_got_talent_be/src/agent/agents/commentatorAgent.ts +++ b/agent_got_talent_be/src/agent/agents/commentatorAgent.ts @@ -1,11 +1,12 @@ import { LlmAgent } from "@google/adk"; +import { getModel } from "../llmFactory.js"; import { PLATFORM_CONTEXT, BRAINROT_PERSONALITY } from "../prompts/shared.js"; import { getCommentThread, countThreadComments, postComment } from "../tools/commentTools.js"; import { listAgents } from "../tools/platformTools.js"; export const commentatorAgent = new LlmAgent({ name: "commentator", - model: "gemini-2.5-flash", + model: getModel({ gemini: "gemini-2.5-flash", claude: "claude-opus-4-6" }), description: "Manages comment chain debates on campaigns. Posts sarcastic, funny brainrot-style comments — roasting, defending, and dropping hot takes.", instruction: `${PLATFORM_CONTEXT} diff --git a/agent_got_talent_be/src/agent/agents/financialAgent.ts b/agent_got_talent_be/src/agent/agents/financialAgent.ts index 0451dc8..d550c74 100644 --- a/agent_got_talent_be/src/agent/agents/financialAgent.ts +++ b/agent_got_talent_be/src/agent/agents/financialAgent.ts @@ -1,11 +1,12 @@ import { LlmAgent } from "@google/adk"; +import { getModel } from "../llmFactory.js"; import { PLATFORM_CONTEXT } from "../prompts/shared.js"; import { listInvestments } from "../tools/investmentTools.js"; import { getPayoutContext, executeInvestorPayouts } from "../tools/payoutTools.js"; export const financialAgent = new LlmAgent({ name: "financial", - model: "gemini-2.5-flash", + model: getModel({ gemini: "gemini-2.5-flash", claude: "claude-opus-4-6" }), description: "Handles investor payout decisions: analyzes topic relevance and decides how much each investor deserves.", instruction: `${PLATFORM_CONTEXT} diff --git a/agent_got_talent_be/src/agent/agents/rootAgent.ts b/agent_got_talent_be/src/agent/agents/rootAgent.ts index 45dd4a8..47e592e 100644 --- a/agent_got_talent_be/src/agent/agents/rootAgent.ts +++ b/agent_got_talent_be/src/agent/agents/rootAgent.ts @@ -1,4 +1,5 @@ import { LlmAgent } from "@google/adk"; +import { getModel } from "../llmFactory.js"; import { PLATFORM_CONTEXT } from "../prompts/shared.js"; import { getCurrentTime } from "../tools/time.js"; import { getPlatformStats, listAgents, listAllCampaigns } from "../tools/platformTools.js"; @@ -10,7 +11,7 @@ import { listPublications } from "../tools/publicationTools.js"; */ export const rootAgent = new LlmAgent({ name: "agent_got_talent", - model: "gemini-2.5-flash", + model: getModel({ gemini: "gemini-2.5-flash", claude: "claude-opus-4-6" }), description: "AgentGotTalent platform assistant for the debug CLI.", instruction: `${PLATFORM_CONTEXT} diff --git a/agent_got_talent_be/src/agent/agents/scoringAgent.ts b/agent_got_talent_be/src/agent/agents/scoringAgent.ts index b57e826..74db8bf 100644 --- a/agent_got_talent_be/src/agent/agents/scoringAgent.ts +++ b/agent_got_talent_be/src/agent/agents/scoringAgent.ts @@ -1,10 +1,11 @@ import { LlmAgent } from "@google/adk"; +import { getModel } from "../llmFactory.js"; import { PLATFORM_CONTEXT } from "../prompts/shared.js"; import { getAllInvestments, updateInvestmentScores } from "../tools/scoringTools.js"; export const scoringAgent = new LlmAgent({ name: "scoring", - model: "gemini-2.5-flash", + model: getModel({ gemini: "gemini-2.5-flash", claude: "claude-opus-4-6" }), description: "Evaluates every investment's platform_influence_score using semantic understanding of topics and the platform's influence rules.", instruction: `${PLATFORM_CONTEXT} diff --git a/agent_got_talent_be/src/agent/llmFactory.ts b/agent_got_talent_be/src/agent/llmFactory.ts new file mode 100644 index 0000000..a90e9e9 --- /dev/null +++ b/agent_got_talent_be/src/agent/llmFactory.ts @@ -0,0 +1,81 @@ +/** + * Centralized LLM model factory. + * + * All ADK agents in this codebase get their model from this factory + * instead of hardcoding a model string. The factory looks at the + * `USE_CLAUDE_GATEWAY` env var and returns either: + * + * - a string (e.g. "gemini-2.5-flash") if the flag is OFF — the + * ADK runtime will resolve it to the native Gemini class. + * + * - an `OakClaude` BaseLlm instance if the flag is ON — agents + * route through the claude-internal-gateway and use Anthropic + * Claude under the hood. + * + * Why a flag instead of hardcoding Claude: + * + * 1. Reversible: flipping `USE_CLAUDE_GATEWAY=false` puts every + * agent back on Gemini without touching code. + * + * 2. Per-environment control: dev can run on Gemini for cheap + * iteration, prod can run on Claude. Or vice versa. + * + * 3. A/B testing: it's trivial to compare agent behavior across + * providers by toggling one env var. + * + * 4. Single source of truth: when we want to change which Claude + * model is the default, we change one file, not six. + * + * Usage in an agent file: + * + * import { getModel } from "../llmFactory.js"; + * + * export const someAgent = new LlmAgent({ + * name: "...", + * model: getModel({ + * gemini: "gemini-2.5-flash", + * claude: "claude-opus-4-6", + * }), + * // ... + * }); + * + * Required env vars: + * + * - `USE_CLAUDE_GATEWAY=true` to route through the gateway. + * If unset or any other value, agents use Gemini directly. + * - When `USE_CLAUDE_GATEWAY=true`: `OAK_GATEWAY_URL` and + * `OAK_PROXY_TOKEN` must be set (read by OakClaude itself). + * - When `USE_CLAUDE_GATEWAY=false` (or unset): `GEMINI_API_KEY` + * must be set (read by @google/adk's Gemini class). + */ + +import { OakClaude } from "./oak_claude_llm.js"; + +export type ModelSpec = { + /** + * Gemini model id used when USE_CLAUDE_GATEWAY is not "true". + * Should be a string the @google/adk Gemini class accepts, + * e.g. "gemini-2.5-flash" or "gemini-2.5-pro". + */ + gemini: string; + /** + * Anthropic Claude model id used when USE_CLAUDE_GATEWAY is "true". + * Must be on the gateway's allowlist; currently + * "claude-opus-4-6" or "claude-sonnet-4-6". + */ + claude: string; +}; + +/** + * Returns the LLM that an ADK agent should use, based on the + * USE_CLAUDE_GATEWAY environment flag. + * + * Returns either a string (resolved by ADK's LLMRegistry to a Gemini + * instance) or an OakClaude BaseLlm instance. + */ +export function getModel(spec: ModelSpec): string | OakClaude { + if (process.env.USE_CLAUDE_GATEWAY === "true") { + return new OakClaude({ model: spec.claude }); + } + return spec.gemini; +} diff --git a/agent_got_talent_be/src/agent/oak_claude_llm.ts b/agent_got_talent_be/src/agent/oak_claude_llm.ts new file mode 100644 index 0000000..cc4855e --- /dev/null +++ b/agent_got_talent_be/src/agent/oak_claude_llm.ts @@ -0,0 +1,436 @@ +/** + * Custom @google/adk BaseLlm subclass that routes Claude requests through + * the claude-internal-gateway. + * + * Zero new npm dependencies. Uses only: + * - @google/adk (already in your package.json) + * - Node's built-in fetch (Node >= 18) + * + * No @anthropic-ai/sdk, no @google/genai (types are inferred from + * @google/adk's own LlmRequest interface), no litellm. Drop this file + * into your project anywhere (e.g. `src/agent/oak_claude_llm.ts`) and + * import it like any other module. + * + * Why this exists: @google/adk 0.6.1 ships native model support only for + * Gemini and Apigee. There is no LiteLlm equivalent in the TypeScript + * ADK and we intentionally avoid the litellm package altogether. + * + * The format translation is the only non-trivial part: ADK passes + * `LlmRequest.contents` in Google GenAI shape (role + parts), while + * the gateway expects Anthropic's `messages` shape (role + content + * blocks). We translate both directions inside this file so the rest + * of the ADK runtime never needs to know it is talking to Claude + * through a proxy. + */ + +import { + BaseLlm, + type BaseLlmConnection, + type LlmRequest, + type LlmResponse, +} from "@google/adk"; + +// We deliberately do not `import type` from @google/genai. Instead we +// derive the parts of the GenAI shape we need from LlmRequest itself +// using TypeScript indexed-access types. That way the only package we +// type-depend on is @google/adk. +type GenAIContent = LlmRequest["contents"][number]; +type GenAIPart = NonNullable[number]; +type GenAIToolList = NonNullable["tools"]>; +type GenAITool = GenAIToolList[number]; + +// --------------------------------------------------------------------------- +// Minimal Anthropic Messages API types — defined inline so we don't need +// the @anthropic-ai/sdk package. Only the fields we actually read are +// declared; anything else returned by the API is harmlessly ignored. +// --------------------------------------------------------------------------- + +type AnthropicTextBlock = { type: "text"; text: string }; +type AnthropicThinkingBlock = { type: "thinking"; thinking: string }; +type AnthropicToolUseBlock = { + type: "tool_use"; + id: string; + name: string; + input: Record; +}; +type AnthropicContentBlock = + | AnthropicTextBlock + | AnthropicThinkingBlock + | AnthropicToolUseBlock + | { type: string; [key: string]: unknown }; + +type AnthropicMessage = { + id: string; + type: "message"; + role: "assistant"; + model: string; + content: AnthropicContentBlock[]; + stop_reason: string | null; + usage: { + input_tokens: number; + output_tokens: number; + }; +}; + +type AnthropicTextContentParam = { type: "text"; text: string }; +type AnthropicToolUseContentParam = { + type: "tool_use"; + id: string; + name: string; + input: Record; +}; +type AnthropicToolResultContentParam = { + type: "tool_result"; + tool_use_id: string; + content: string; +}; +type AnthropicContentBlockParam = + | AnthropicTextContentParam + | AnthropicToolUseContentParam + | AnthropicToolResultContentParam; + +type AnthropicMessageParam = { + role: "user" | "assistant"; + content: AnthropicContentBlockParam[]; +}; + +type AnthropicTool = { + name: string; + description?: string; + input_schema: { + type: "object"; + properties?: Record; + required?: string[]; + }; +}; + +type AnthropicMessageCreateBody = { + model: string; + max_tokens: number; + messages: AnthropicMessageParam[]; + system?: string; + tools?: AnthropicTool[]; + thinking?: { type: "adaptive" }; + temperature?: number; + top_p?: number; + // Always false for our use; the gateway handles upstream streaming + // internally and returns a non-streaming response. + stream?: false; +}; + +// --------------------------------------------------------------------------- +// Adapter +// --------------------------------------------------------------------------- + +export type OakClaudeOptions = { + /** Model id, e.g. "claude-opus-4-6" or "claude-sonnet-4-6". */ + model: string; + /** Base URL of the gateway, e.g. "https://tunnel.cfsprotocol.com". */ + gatewayUrl?: string; + /** Proxy token issued by the gateway admin. */ + proxyToken?: string; + /** Hard cap on output tokens for any single request. */ + maxOutputTokens?: number; + /** + * Whether to enable adaptive extended thinking. Defaults to true, + * matching the gateway's intended use case. + */ + thinking?: boolean; +}; + +const DEFAULT_GATEWAY_URL = + process.env.OAK_GATEWAY_URL || "http://127.0.0.1:8765"; +const DEFAULT_MAX_OUTPUT_TOKENS = 16_000; +const ANTHROPIC_VERSION = "2023-06-01"; + +export class OakClaude extends BaseLlm { + private readonly gatewayUrl: string; + private readonly proxyToken: string; + private readonly maxOutputTokens: number; + private readonly thinkingEnabled: boolean; + + // BaseLlm.supportedModels is used by the ADK LLMRegistry for string + // lookup. We DO NOT register OakClaude with the registry because we + // expect users to instantiate it directly and pass the instance to + // LlmAgent's `model` field. Leaving this empty is fine. + static override readonly supportedModels: Array = []; + + constructor(options: OakClaudeOptions) { + super({ model: options.model }); + this.gatewayUrl = (options.gatewayUrl ?? DEFAULT_GATEWAY_URL).replace( + /\/+$/, + "", + ); + const token = options.proxyToken ?? process.env.OAK_PROXY_TOKEN; + if (!token) { + throw new Error( + "OakClaude: proxyToken not provided and OAK_PROXY_TOKEN env var not set", + ); + } + this.proxyToken = token; + this.maxOutputTokens = options.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS; + this.thinkingEnabled = options.thinking ?? true; + } + + /** + * The contract that ADK calls. Yields one or more LlmResponse objects. + * + * Implementation note: we always send `stream: false` to the gateway. + * The gateway internally streams from Anthropic to handle long + * (high-thinking) requests safely, then returns a single JSON + * response. So this adapter only needs plain `fetch` and `await + * response.json()` — no SSE parsing in the client. + */ + async *generateContentAsync( + llmRequest: LlmRequest, + _stream: boolean = false, + ): AsyncGenerator { + const { messages, system } = this.translateRequest(llmRequest); + const config = llmRequest.config ?? {}; + const maxTokens = + (config.maxOutputTokens as number | undefined) ?? this.maxOutputTokens; + + const tools = this.translateTools(config.tools as GenAIToolList | undefined); + + const body: AnthropicMessageCreateBody = { + model: llmRequest.model ?? this.model, + max_tokens: maxTokens, + messages, + ...(system ? { system } : {}), + ...(tools.length > 0 ? { tools } : {}), + ...(this.thinkingEnabled + ? { thinking: { type: "adaptive" as const } } + : {}), + ...(typeof config.temperature === "number" + ? { temperature: config.temperature } + : {}), + ...(typeof config.topP === "number" ? { top_p: config.topP } : {}), + }; + + const message = await this.callGateway(body); + yield this.translateResponse(message); + } + + /** + * BaseLlm requires a `connect` method for live (bidi) streaming. We do + * not support that mode for the gateway path, so we throw a clear + * error if the runtime ever tries to use it. + */ + async connect(_llmRequest: LlmRequest): Promise { + throw new Error( + "OakClaude does not support live (bidi) streaming connections. " + + "Use generateContentAsync instead.", + ); + } + + // -- HTTP ---------------------------------------------------------------- + + private async callGateway( + body: AnthropicMessageCreateBody, + ): Promise { + const url = `${this.gatewayUrl}/v1/messages`; + const response = await fetch(url, { + method: "POST", + headers: { + "x-api-key": this.proxyToken, + "anthropic-version": ANTHROPIC_VERSION, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + // Read the body so the user gets actionable info, but truncate so + // we don't dump huge payloads to logs. + const errorText = (await response.text()).slice(0, 1000); + throw new Error( + `claude-internal-gateway returned ${response.status}: ${errorText}`, + ); + } + return (await response.json()) as AnthropicMessage; + } + + // -- Translation helpers ------------------------------------------------ + + private translateRequest(req: LlmRequest): { + messages: AnthropicMessageParam[]; + system?: string; + } { + const messages: AnthropicMessageParam[] = []; + for (const content of req.contents) { + const role: "user" | "assistant" = + content.role === "model" ? "assistant" : "user"; + const blocks: AnthropicContentBlockParam[] = []; + for (const part of content.parts ?? []) { + if (part.text !== undefined && !part.thought) { + blocks.push({ type: "text", text: part.text }); + } else if (part.functionCall) { + blocks.push({ + type: "tool_use", + id: + part.functionCall.id ?? + `tool_${Math.random().toString(36).slice(2)}`, + name: part.functionCall.name ?? "unknown_tool", + input: (part.functionCall.args ?? {}) as Record, + }); + } else if (part.functionResponse) { + blocks.push({ + type: "tool_result", + tool_use_id: part.functionResponse.id ?? "", + content: JSON.stringify(part.functionResponse.response ?? {}), + }); + } + } + if (blocks.length > 0) { + messages.push({ role, content: blocks }); + } + } + const sysInstruction = req.config?.systemInstruction; + let system: string | undefined; + if (typeof sysInstruction === "string") { + system = sysInstruction; + } else if ( + sysInstruction && + typeof sysInstruction === "object" && + "parts" in sysInstruction + ) { + // Structural extraction: any object with a `parts` array of + // {text} entries works, regardless of its declared type. + const partsArr = (sysInstruction as { parts?: Array<{ text?: string }> }) + .parts; + if (Array.isArray(partsArr)) { + system = partsArr + .map((p) => p.text ?? "") + .filter(Boolean) + .join("\n"); + } + } + return { messages, system }; + } + + private translateTools(tools?: GenAIToolList): AnthropicTool[] { + if (!tools || tools.length === 0) return []; + const out: AnthropicTool[] = []; + for (const tool of tools) { + // The genai Tool type is a union (regular Tool vs CallableTool). + // Only regular tools carry `functionDeclarations`. Narrow with a + // structural check before reading the field. + if ( + !tool || + typeof tool !== "object" || + !("functionDeclarations" in tool) + ) { + continue; + } + const declarations = (tool as { functionDeclarations?: Array<{ + name?: string; + description?: string; + parameters?: unknown; + }> }).functionDeclarations ?? []; + for (const fn of declarations) { + out.push({ + name: fn.name ?? "unknown", + description: fn.description, + input_schema: this.geminiSchemaToJsonSchema( + fn.parameters, + ) as AnthropicTool["input_schema"], + }); + } + } + return out; + } + + /** + * Convert a Gemini-flavored schema to a strict JSON Schema. + * + * Gemini uses uppercase enum values for the `type` field (OBJECT, + * STRING, INTEGER, NUMBER, BOOLEAN, ARRAY) while Anthropic and the + * rest of the JSON Schema world expect lowercase. We walk the + * schema recursively and lowercase any string `type` value, leaving + * everything else untouched. + * + * If the input is missing or unrecognized we fall back to the + * minimal valid Anthropic input_schema: an empty object schema. + */ + private geminiSchemaToJsonSchema(schema: unknown): Record { + const fallback = { type: "object", properties: {} }; + if (!schema || typeof schema !== "object") { + return fallback; + } + const normalized = this.normalizeSchemaNode(schema) as Record< + string, + unknown + >; + // Anthropic requires the top-level schema to declare type=object. + // If the model handed us something that lacks it (e.g. a bare + // properties bag), force it. + if (normalized.type !== "object") { + normalized.type = "object"; + } + if (!("properties" in normalized)) { + normalized.properties = {}; + } + return normalized; + } + + private normalizeSchemaNode(node: unknown): unknown { + if (Array.isArray(node)) { + return node.map((n) => this.normalizeSchemaNode(n)); + } + if (!node || typeof node !== "object") { + return node; + } + const out: Record = {}; + for (const [key, value] of Object.entries(node as Record)) { + if (key === "type" && typeof value === "string") { + // Gemini emits OBJECT/STRING/INTEGER/etc; JSON Schema expects + // lowercase. We also map a couple of synonyms defensively. + const lower = value.toLowerCase(); + out[key] = lower === "integer" ? "integer" : lower; + } else if (typeof value === "object" && value !== null) { + out[key] = this.normalizeSchemaNode(value); + } else { + out[key] = value; + } + } + return out; + } + + private translateResponse(message: AnthropicMessage): LlmResponse { + const parts: GenAIPart[] = []; + for (const block of message.content) { + if (block.type === "thinking") { + const tb = block as AnthropicThinkingBlock; + parts.push({ text: tb.thinking, thought: true }); + } else if (block.type === "text") { + const tb = block as AnthropicTextBlock; + parts.push({ text: tb.text }); + } else if (block.type === "tool_use") { + const ub = block as AnthropicToolUseBlock; + parts.push({ + functionCall: { + id: ub.id, + name: ub.name, + args: ub.input, + }, + }); + } + // Unknown block types are ignored on purpose: this keeps the + // adapter forward-compatible with any new content block types + // Anthropic adds in the future, at the cost of silently dropping + // them. If you need to surface a new block type, add a branch + // here. + } + return { + content: { role: "model", parts }, + // We deliberately omit finishReason because Anthropic's stop_reason + // string ("end_turn", "max_tokens", etc.) does not match Google's + // FinishReason enum and ADK does not require it for normal flow. + usageMetadata: { + promptTokenCount: message.usage.input_tokens, + candidatesTokenCount: message.usage.output_tokens, + totalTokenCount: + message.usage.input_tokens + message.usage.output_tokens, + }, + }; + } +}