feat: Add Anthropic Claude API support to ifcchat

Add a provider selector (OpenAI / Anthropic) to the ifcchat web UI,
allowing users to use their Anthropic API key with Claude models
instead of only OpenAI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DesertSpringsCivil
2026-04-02 18:50:27 -06:00
parent 9d42f4c1ee
commit 95851ff94c
2 changed files with 203 additions and 21 deletions
+182 -16
View File
@@ -7,10 +7,57 @@ const sendBtn = $("send");
const inputEl = $("input");
const apiKeyEl = $("apiKey");
const modelEl = $("model");
const providerEl = $("provider");
const ifcFileEl = $("ifcFile");
const newBtn = $("newModel");
const downloadBtn = $("downloadIfc");
// ---- Provider switching ----
const PROVIDER_MODELS = {
openai: ["gpt-5", "gpt-4.1"],
anthropic: ["claude-sonnet-4-6", "claude-opus-4-6", "claude-haiku-4-5-20251001"],
};
const PROVIDER_LABELS = {
openai: "OpenAI API key",
anthropic: "Anthropic API key",
};
const PROVIDER_PLACEHOLDERS = {
openai: "sk-...",
anthropic: "sk-ant-...",
};
function getProvider() {
return providerEl.value;
}
function updateProviderUI() {
const provider = getProvider();
$("apiKeyLabel").innerHTML = `${PROVIDER_LABELS[provider]}<span class="small">stored in browser memory; only sent to provider servers</span>`;
apiKeyEl.placeholder = PROVIDER_PLACEHOLDERS[provider];
// Enable/disable model optgroups and select first available model
for (const [key, models] of Object.entries(PROVIDER_MODELS)) {
const group = $(`modelGroup${key.charAt(0).toUpperCase() + key.slice(1)}`);
if (!group) continue;
const isActive = key === provider;
group.disabled = !isActive;
for (const opt of group.querySelectorAll("option")) {
opt.disabled = !isActive;
}
}
modelEl.value = PROVIDER_MODELS[provider][0];
// Reset conversation on provider switch
openaiInputItems = [];
anthropicMessages = [];
}
providerEl.addEventListener("change", updateProviderUI);
function setBusy(isBusy, reason = "") {
const controls = [
$("send"),
@@ -75,8 +122,7 @@ function callWorker(type, payload = {}) {
});
}
// ---- OpenAI Responses API tool schemas (should match ifcmcp.core openai_tools()) ----
// Docs show Responses API function_call items + function_call_output loop. :contentReference[oaicite:4]{index=4}
// ---- Tool schemas (OpenAI Responses API format) ----
const tools = [
{
type: "function", name: "ifc_new", description: "Create a new empty IFC model in memory.",
@@ -146,6 +192,15 @@ const tools = [
},
];
// Convert OpenAI tool format to Anthropic tool format
function toolsForAnthropic() {
return tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.parameters,
}));
}
const SYSTEM_INSTRUCTIONS = `
You are an IFC copilot running in a browser. You can call tools to inspect or modify the currently loaded IFC model.
Rules:
@@ -156,7 +211,15 @@ Rules:
Be concise. Avoid dumping huge trees unless asked.
`;
let inputItems = []; // running conversation state (Responses API style)
// ---- OpenAI state ----
let openaiInputItems = [];
// ---- Anthropic state ----
let anthropicMessages = [];
// ============================================================
// OpenAI Responses API
// ============================================================
async function openAIResponsesCreate({ apiKey, model, input, tools }) {
const res = await fetch("https://api.openai.com/v1/responses", {
@@ -180,7 +243,7 @@ async function openAIResponsesCreate({ apiKey, model, input, tools }) {
return await res.json();
}
function extractAssistantText(response) {
function extractAssistantTextOpenAI(response) {
const out = [];
for (const item of response.output ?? []) {
if (item.type === "message" && item.role === "assistant") {
@@ -192,27 +255,23 @@ function extractAssistantText(response) {
return out.join("\n").trim();
}
async function runAgentTurn(userText) {
async function runOpenAITurn(userText) {
const apiKey = apiKeyEl.value.trim();
if (!apiKey) throw new Error("Missing API key");
// Add user message
inputItems.push({ role: "user", content: userText });
openaiInputItems.push({ role: "user", content: userText });
// Tool-calling loop (Responses API): append response.output, execute function_call items, append function_call_output.
for (let i = 0; i < 64; i++) {
const response = await openAIResponsesCreate({
apiKey,
model: modelEl.value,
input: inputItems,
input: openaiInputItems,
tools,
});
// Keep ALL output items (incl reasoning/tool calls) in the running state.
inputItems.push(...(response.output ?? []));
openaiInputItems.push(...(response.output ?? []));
// Show any assistant text immediately
const text = extractAssistantText(response);
const text = extractAssistantTextOpenAI(response);
if (text) addMessage("assistant", text);
const calls = (response.output ?? []).filter((x) => x.type === "function_call");
@@ -227,8 +286,7 @@ async function runAgentTurn(userText) {
const toolRes = await callWorker("toolCall", { name: call.name, args });
// Feed tool result back to the model
inputItems.push({
openaiInputItems.push({
type: "function_call_output",
call_id: call.call_id,
output: JSON.stringify(toolRes.result),
@@ -241,6 +299,113 @@ async function runAgentTurn(userText) {
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
}
// ============================================================
// Anthropic Messages API
// ============================================================
async function anthropicMessagesCreate({ apiKey, model, system, messages, tools }) {
const res = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"anthropic-dangerous-direct-browser-access": "true",
},
body: JSON.stringify({
model,
max_tokens: 4096,
system,
tools,
messages,
}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Anthropic error ${res.status}: ${text}`);
}
return await res.json();
}
function extractAssistantTextAnthropic(response) {
const out = [];
for (const block of response.content ?? []) {
if (block.type === "text") out.push(block.text);
}
return out.join("\n").trim();
}
async function runAnthropicTurn(userText) {
const apiKey = apiKeyEl.value.trim();
if (!apiKey) throw new Error("Missing API key");
anthropicMessages.push({ role: "user", content: userText });
const claudeTools = toolsForAnthropic();
for (let i = 0; i < 64; i++) {
const response = await anthropicMessagesCreate({
apiKey,
model: modelEl.value,
system: SYSTEM_INSTRUCTIONS,
messages: anthropicMessages,
tools: claudeTools,
});
// Show any assistant text
const text = extractAssistantTextAnthropic(response);
if (text) addMessage("assistant", text);
// Append the full assistant response to conversation history
anthropicMessages.push({ role: "assistant", content: response.content });
// Check for tool use
const toolUseBlocks = (response.content ?? []).filter((b) => b.type === "tool_use");
if (response.stop_reason !== "tool_use" || toolUseBlocks.length === 0) return;
// Execute each tool call and collect results
const toolResultBlocks = [];
for (const toolUse of toolUseBlocks) {
const args = toolUse.input ?? {};
addMessage("tool", `${toolUse.name}(${JSON.stringify(args)})`);
let resultContent;
try {
const toolRes = await callWorker("toolCall", { name: toolUse.name, args });
resultContent = JSON.stringify(toolRes.result);
} catch (e) {
resultContent = JSON.stringify({ error: e.message });
}
toolResultBlocks.push({
type: "tool_result",
tool_use_id: toolUse.id,
content: resultContent,
});
addMessage("tool", `${toolUse.name}: ${resultContent}`);
}
// Feed all tool results back as a single user message
anthropicMessages.push({ role: "user", content: toolResultBlocks });
}
addMessage("assistant", "I hit the tool-call loop limit. Try narrowing your request.");
}
// ============================================================
// Unified agent turn
// ============================================================
async function runAgentTurn(userText) {
if (getProvider() === "anthropic") {
return runAnthropicTurn(userText);
}
return runOpenAITurn(userText);
}
sendBtn.onclick = async () => {
const text = inputEl.value.trim();
if (!text) return;
@@ -312,9 +477,10 @@ downloadBtn.onclick = async () => {
try {
setBusy(true, "Initializing Pyodide and IfcOpenShell for in-memory IFC access…");
await callWorker("init", {});
updateProviderUI();
setBusy(false, "Ready");
} catch (e) {
setBusy(true, "Error");
addMessage("assistant", `Worker init failed: ${e.message}`);
}
})();
})();
+21 -5
View File
@@ -143,7 +143,8 @@
padding: 8px 10px;
}
#model {
#model,
#provider {
background: white;
color: gray;
border: solid 1px #eee;
@@ -239,14 +240,22 @@
<main>
<section class="side">
<div class="row">
<label>OpenAI API key<span class="small">stored in browser memory; only sent to OpenAI servers</span></label>
<label>Provider</label>
<select id="provider" style="width: 100%;">
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic (Claude)</option>
</select>
</div>
<div class="row">
<label id="apiKeyLabel">OpenAI API key<span class="small">stored in browser memory; only sent to provider servers</span></label>
<input id="apiKey" type="password" placeholder="sk-..." autocomplete="off" style="width: 100%;" />
</div>
<hr />
<div class="row" style="margin-bottom: 10px;">
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to OpenAI servers</span></label>
<label>IFC model context<span class="small">stored in browser memory; only information extracted through MCP commands sent to provider servers</span></label>
<div class="btn-row">
<label class="btn" id="browseBtn" for="ifcFile" role="button" tabindex="0">
@@ -296,8 +305,15 @@
<strong>IfcOpenShell AI Assistant</strong>
<select id="model">
<option value="gpt-5">gpt-5</option>
<option value="gpt-4.1">gpt-4.1</option>
<optgroup id="modelGroupOpenai" label="OpenAI">
<option value="gpt-5">gpt-5</option>
<option value="gpt-4.1">gpt-4.1</option>
</optgroup>
<optgroup id="modelGroupAnthropic" label="Anthropic" disabled>
<option value="claude-sonnet-4-6" disabled>claude-sonnet-4-6</option>
<option value="claude-opus-4-6" disabled>claude-opus-4-6</option>
<option value="claude-haiku-4-5-20251001" disabled>claude-haiku-4-5</option>
</optgroup>
</select>
</div>
<div class="row">