diff --git a/adapters/n8n/README.md b/adapters/n8n/README.md deleted file mode 100644 index 0aeba539..00000000 --- a/adapters/n8n/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# n8n-nodes-identark - -This is an n8n community node for [IdentArk](https://github.com/identark/identark) — the secure AI agent gateway. - -**IdentArk lets your n8n workflows invoke LLMs without exposing API keys.** Your credentials stay in a secure vault; n8n only holds a session reference. - -## Installation - -### Community Nodes (Recommended) - -1. Go to **Settings > Community Nodes** -2. Select **Install** -3. Enter `n8n-nodes-identark` -4. Agree to the risks and click **Install** - -### Manual Installation - -```bash -cd ~/.n8n/custom -npm install n8n-nodes-identark -``` - -## Prerequisites - -1. A IdentArk account — sign up at your control plane (e.g., `https://identark-cloud.fly.dev`) -2. An API key from `/v1/orgs/signup` -3. LLM credentials registered via `/v1/credentials` - -## Operations - -### Invoke LLM - -Send a message to an LLM and receive a response. Supports: -- Continuing conversations via `session_id` -- System, user, and assistant roles -- Automatic cost tracking - -### Create Session - -Create a new agent session with explicit configuration: -- Model selection (GPT-4o, Claude, Mistral, etc.) -- Provider selection (OpenAI, Anthropic, Azure, Bedrock, Mistral) -- Cost cap enforcement -- Credential reference (vault path) - -### Get Session Cost - -Retrieve the running cost for a session — useful for monitoring and billing workflows. - -## Example Workflow - -1. **Create Session** → Configure model, provider, cost cap -2. **Invoke LLM** → Send user message, receive response -3. **Get Session Cost** → Check spend before next invocation - -## Credentials - -| Field | Description | -|-------|-------------| -| API Key | Your IdentArk API key (starts with `csk_`) | -| Control Plane URL | Your IdentArk instance URL | - -## Data Residency - -IdentArk supports UK/EU data residency: -- **Azure OpenAI UK South** — GPT-4o inference stays in UK -- **AWS Bedrock eu-west-2** — Claude in London -- **Mistral AI** — EU data centres (French company) - -Configure your session with the appropriate provider to ensure compliance. - -## License - -AGPL-3.0 — see [LICENSE](../../LICENSE) - -## Links - -- [IdentArk SDK](https://github.com/identark/identark) -- [n8n Community Nodes](https://docs.n8n.io/integrations/community-nodes/) diff --git a/adapters/n8n/credentials/IdentArkApi.credentials.ts b/adapters/n8n/credentials/IdentArkApi.credentials.ts deleted file mode 100644 index d523b796..00000000 --- a/adapters/n8n/credentials/IdentArkApi.credentials.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { - IAuthenticateGeneric, - ICredentialTestRequest, - ICredentialType, - INodeProperties, -} from 'n8n-workflow'; - -export class IdentArkApi implements ICredentialType { - name = 'identarkApi'; - displayName = 'IdentArk API'; - documentationUrl = 'https://github.com/identark/sdk'; - properties: INodeProperties[] = [ - { - displayName: 'API Key', - name: 'apiKey', - type: 'string', - typeOptions: { password: true }, - default: '', - required: true, - description: 'Your IdentArk API key (starts with iak_)', - }, - { - displayName: 'Control Plane URL', - name: 'baseUrl', - type: 'string', - default: 'https://identark-cloud.fly.dev', - required: true, - description: 'IdentArk control plane URL', - }, - ]; - - authenticate: IAuthenticateGeneric = { - type: 'generic', - properties: { - headers: { - Authorization: '={{"Bearer " + $credentials.apiKey}}', - }, - }, - }; - - test: ICredentialTestRequest = { - request: { - baseURL: '={{$credentials.baseUrl}}', - url: '/health', - method: 'GET', - }, - }; -} diff --git a/adapters/n8n/gulpfile.js b/adapters/n8n/gulpfile.js deleted file mode 100644 index af36d5a4..00000000 --- a/adapters/n8n/gulpfile.js +++ /dev/null @@ -1,7 +0,0 @@ -const { src, dest } = require('gulp'); - -function buildIcons() { - return src('nodes/**/*.svg').pipe(dest('dist/nodes')); -} - -exports['build:icons'] = buildIcons; diff --git a/adapters/n8n/index.ts b/adapters/n8n/index.ts deleted file mode 100644 index 8dcc32e1..00000000 --- a/adapters/n8n/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './credentials/IdentArkApi.credentials'; -export * from './nodes/IdentArk/IdentArk.node'; diff --git a/adapters/n8n/nodes/IdentArk/IdentArk.node.ts b/adapters/n8n/nodes/IdentArk/IdentArk.node.ts deleted file mode 100644 index b2cd0435..00000000 --- a/adapters/n8n/nodes/IdentArk/IdentArk.node.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { - IExecuteFunctions, - INodeExecutionData, - INodeType, - INodeTypeDescription, - NodeOperationError, -} from 'n8n-workflow'; - -export class IdentArk implements INodeType { - description: INodeTypeDescription = { - displayName: 'IdentArk', - name: 'identark', - icon: 'file:identark.svg', - group: ['transform'], - version: 1, - subtitle: '={{$parameter["operation"]}}', - description: 'Secure AI agent gateway — invoke LLMs without exposing credentials', - defaults: { - name: 'IdentArk', - }, - inputs: ['main'], - outputs: ['main'], - credentials: [ - { - name: 'identarkApi', - required: true, - }, - ], - properties: [ - { - displayName: 'Operation', - name: 'operation', - type: 'options', - noDataExpression: true, - options: [ - { - name: 'Invoke LLM', - value: 'invokeLlm', - description: 'Send messages to an LLM and get a response', - action: 'Invoke LLM', - }, - { - name: 'Create Session', - value: 'createSession', - description: 'Create a new agent session with specific configuration', - action: 'Create session', - }, - { - name: 'Get Session Cost', - value: 'getSessionCost', - description: 'Get the current cost for a session', - action: 'Get session cost', - }, - ], - default: 'invokeLlm', - }, - - // ── Invoke LLM fields ───────────────────────────────────────────────── - { - displayName: 'Message', - name: 'message', - type: 'string', - typeOptions: { - rows: 4, - }, - default: '', - required: true, - displayOptions: { - show: { - operation: ['invokeLlm'], - }, - }, - description: 'The message to send to the LLM', - }, - { - displayName: 'Session ID', - name: 'sessionId', - type: 'string', - default: '', - displayOptions: { - show: { - operation: ['invokeLlm', 'getSessionCost'], - }, - }, - description: 'Session ID to continue a conversation (optional for invokeLlm)', - }, - { - displayName: 'Role', - name: 'role', - type: 'options', - options: [ - { name: 'User', value: 'user' }, - { name: 'System', value: 'system' }, - { name: 'Assistant', value: 'assistant' }, - ], - default: 'user', - displayOptions: { - show: { - operation: ['invokeLlm'], - }, - }, - description: 'The role of the message sender', - }, - - // ── Create Session fields ───────────────────────────────────────────── - { - displayName: 'Agent ID', - name: 'agentId', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['createSession'], - }, - }, - description: 'Identifier for this agent', - }, - { - displayName: 'Model', - name: 'model', - type: 'options', - options: [ - { name: 'GPT-4o', value: 'gpt-4o' }, - { name: 'GPT-4o Mini', value: 'gpt-4o-mini' }, - { name: 'Claude Sonnet 4', value: 'claude-sonnet-4-6' }, - { name: 'Claude Haiku', value: 'claude-haiku-4-5-20251001' }, - { name: 'Mistral Large', value: 'mistral-large-latest' }, - { name: 'Mistral Small', value: 'mistral-small-latest' }, - ], - default: 'gpt-4o', - displayOptions: { - show: { - operation: ['createSession'], - }, - }, - description: 'The LLM model to use', - }, - { - displayName: 'Provider', - name: 'provider', - type: 'options', - options: [ - { name: 'OpenAI', value: 'openai' }, - { name: 'Anthropic', value: 'anthropic' }, - { name: 'Mistral AI (EU)', value: 'mistral' }, - { name: 'Azure OpenAI (UK)', value: 'azure_openai' }, - { name: 'AWS Bedrock (UK)', value: 'bedrock' }, - ], - default: 'openai', - displayOptions: { - show: { - operation: ['createSession'], - }, - }, - description: 'The LLM provider', - }, - { - displayName: 'Credential Reference', - name: 'credentialRef', - type: 'string', - default: '', - required: true, - displayOptions: { - show: { - operation: ['createSession'], - }, - }, - description: 'Vault path to the LLM credential (e.g., secret/orgs/{org}/providers/openai)', - }, - { - displayName: 'Cost Cap (USD)', - name: 'costCapUsd', - type: 'number', - default: 5.0, - displayOptions: { - show: { - operation: ['createSession'], - }, - }, - description: 'Maximum cost allowed for this session', - }, - ], - }; - - async execute(this: IExecuteFunctions): Promise { - const items = this.getInputData(); - const returnData: INodeExecutionData[] = []; - const operation = this.getNodeParameter('operation', 0) as string; - const credentials = await this.getCredentials('identarkApi'); - const baseUrl = credentials.baseUrl as string; - - for (let i = 0; i < items.length; i++) { - try { - let responseData: object; - - if (operation === 'invokeLlm') { - const message = this.getNodeParameter('message', i) as string; - const role = this.getNodeParameter('role', i) as string; - const sessionId = this.getNodeParameter('sessionId', i) as string; - - const body: Record = { - new_messages: [{ role, content: message }], - }; - if (sessionId) { - body.session_id = sessionId; - } - - responseData = await this.helpers.httpRequestWithAuthentication.call( - this, - 'identarkApi', - { - method: 'POST', - url: `${baseUrl}/v1/llm/invoke`, - body, - json: true, - }, - ); - } else if (operation === 'createSession') { - const agentId = this.getNodeParameter('agentId', i) as string; - const model = this.getNodeParameter('model', i) as string; - const provider = this.getNodeParameter('provider', i) as string; - const credentialRef = this.getNodeParameter('credentialRef', i) as string; - const costCapUsd = this.getNodeParameter('costCapUsd', i) as number; - - responseData = await this.helpers.httpRequestWithAuthentication.call( - this, - 'identarkApi', - { - method: 'POST', - url: `${baseUrl}/v1/sessions`, - body: { - agent_id: agentId, - model, - provider, - credential_ref: credentialRef, - cost_cap_usd: costCapUsd, - }, - json: true, - }, - ); - } else if (operation === 'getSessionCost') { - const sessionId = this.getNodeParameter('sessionId', i) as string; - - if (!sessionId) { - throw new NodeOperationError( - this.getNode(), - 'Session ID is required for Get Session Cost', - ); - } - - responseData = await this.helpers.httpRequestWithAuthentication.call( - this, - 'identarkApi', - { - method: 'GET', - url: `${baseUrl}/v1/sessions/cost`, - qs: { session_id: sessionId }, - json: true, - }, - ); - } else { - throw new NodeOperationError(this.getNode(), `Unknown operation: ${operation}`); - } - - returnData.push({ json: responseData }); - } catch (error) { - if (this.continueOnFail()) { - returnData.push({ - json: { error: (error as Error).message }, - pairedItem: { item: i }, - }); - continue; - } - throw error; - } - } - - return [returnData]; - } -} diff --git a/adapters/n8n/nodes/IdentArk/identark.svg b/adapters/n8n/nodes/IdentArk/identark.svg deleted file mode 100644 index 898d6c68..00000000 --- a/adapters/n8n/nodes/IdentArk/identark.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/adapters/n8n/package.json b/adapters/n8n/package.json deleted file mode 100644 index 1d15ac3f..00000000 --- a/adapters/n8n/package.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "name": "n8n-nodes-identark", - "version": "1.0.0", - "description": "n8n community node for IdentArk — secure AI agent gateway", - "keywords": [ - "n8n-community-node-package", - "n8n", - "identark", - "ai", - "llm", - "openai", - "anthropic", - "gateway" - ], - "license": "AGPL-3.0-only", - "homepage": "https://github.com/identark/sdk", - "author": { - "name": "Gold Okpa", - "email": "gold@identark.io" - }, - "repository": { - "type": "git", - "url": "https://github.com/identark/sdk.git" - }, - "main": "dist/index.js", - "scripts": { - "build": "tsc && gulp build:icons", - "dev": "tsc --watch", - "lint": "eslint nodes credentials --ext .ts", - "prepublishOnly": "npm run build" - }, - "files": [ - "dist" - ], - "n8n": { - "n8nNodesApiVersion": 1, - "credentials": [ - "dist/credentials/IdentArkApi.credentials.js" - ], - "nodes": [ - "dist/nodes/IdentArk/IdentArk.node.js" - ] - }, - "devDependencies": { - "@types/node": "^20.0.0", - "eslint": "^8.57.0", - "gulp": "^4.0.2", - "n8n-workflow": "^1.0.0", - "typescript": "^5.4.0" - }, - "peerDependencies": { - "n8n-workflow": "*" - } -} diff --git a/adapters/n8n/tsconfig.json b/adapters/n8n/tsconfig.json deleted file mode 100644 index 06af5ba2..00000000 --- a/adapters/n8n/tsconfig.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "declaration": true, - "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "noImplicitThis": true, - "alwaysStrict": true, - "noUnusedLocals": false, - "noUnusedParameters": false, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": false, - "inlineSourceMap": true, - "inlineSources": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "moduleResolution": "node", - "outDir": "./dist", - "rootDir": ".", - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "esModuleInterop": true - }, - "include": [ - "credentials/**/*.ts", - "nodes/**/*.ts" - ], - "exclude": [ - "node_modules", - "dist" - ] -} diff --git a/identark/pricing.py b/identark/pricing.py index c2f00d25..7dd4d84f 100644 --- a/identark/pricing.py +++ b/identark/pricing.py @@ -17,43 +17,40 @@ import logging import os from pathlib import Path -from typing import TYPE_CHECKING, TypedDict, cast - -if TYPE_CHECKING: - from typing import Any +from typing import TypedDict logger = logging.getLogger("identark.pricing") class ModelPricing(TypedDict): - input: float # USD per 1M tokens + input: float # USD per 1M tokens output: float # USD per 1M tokens # Bundled defaults — updated periodically with SDK releases _BUNDLED_PRICING: dict[str, ModelPricing] = { # OpenAI - "gpt-4o": {"input": 2.50, "output": 10.00}, - "gpt-4o-mini": {"input": 0.15, "output": 0.60}, - "gpt-4-turbo": {"input": 10.00, "output": 30.00}, - "gpt-3.5-turbo": {"input": 0.50, "output": 1.50}, - "o1": {"input": 15.00, "output": 60.00}, - "o1-mini": {"input": 3.00, "output": 12.00}, + "gpt-4o": {"input": 2.50, "output": 10.00}, + "gpt-4o-mini": {"input": 0.15, "output": 0.60}, + "gpt-4-turbo": {"input": 10.00, "output": 30.00}, + "gpt-3.5-turbo": {"input": 0.50, "output": 1.50}, + "o1": {"input": 15.00, "output": 60.00}, + "o1-mini": {"input": 3.00, "output": 12.00}, # Anthropic - "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00}, - "claude-3-5-haiku-20241022": {"input": 0.80, "output": 4.00}, - "claude-3-opus-20240229": {"input": 15.00, "output": 75.00}, - "claude-sonnet-4-20250514": {"input": 3.00, "output": 15.00}, - "claude-opus-4-20250514": {"input": 15.00, "output": 75.00}, + "claude-3-5-sonnet-20241022": {"input": 3.00, "output": 15.00}, + "claude-3-5-haiku-20241022": {"input": 0.80, "output": 4.00}, + "claude-3-opus-20240229": {"input": 15.00, "output": 75.00}, + "claude-sonnet-4-20250514": {"input": 3.00, "output": 15.00}, + "claude-opus-4-20250514": {"input": 15.00, "output": 75.00}, # Mistral (EU) - "mistral-large-latest": {"input": 2.00, "output": 6.00}, - "mistral-small-latest": {"input": 0.20, "output": 0.60}, - "open-mistral-nemo": {"input": 0.15, "output": 0.15}, - "codestral-latest": {"input": 0.20, "output": 0.60}, + "mistral-large-latest": {"input": 2.00, "output": 6.00}, + "mistral-small-latest": {"input": 0.20, "output": 0.60}, + "open-mistral-nemo": {"input": 0.15, "output": 0.15}, + "codestral-latest": {"input": 0.20, "output": 0.60}, # Google Gemini - "gemini-1.5-pro": {"input": 1.25, "output": 5.00}, - "gemini-1.5-flash": {"input": 0.075, "output": 0.30}, - "gemini-2.0-flash-exp": {"input": 0.10, "output": 0.40}, + "gemini-1.5-pro": {"input": 1.25, "output": 5.00}, + "gemini-1.5-flash": {"input": 0.075, "output": 0.30}, + "gemini-2.0-flash-exp": {"input": 0.10, "output": 0.40}, } # Active pricing table — starts as bundled, can be overridden @@ -80,6 +77,7 @@ def _fetch_remote_pricing(url: str) -> dict[str, ModelPricing] | None: """Fetch pricing from a remote URL (sync, best-effort).""" try: import urllib.request + with urllib.request.urlopen(url, timeout=5) as resp: data: dict[str, ModelPricing] = json.loads(resp.read().decode()) logger.info("Fetched pricing from %s", url) @@ -139,9 +137,7 @@ def estimate_cost( logger.debug("Unknown model %s, using fallback pricing", model) return (input_tokens + output_tokens) * 0.000_010 - return ( - input_tokens * pricing["input"] + output_tokens * pricing["output"] - ) / 1_000_000 + return (input_tokens * pricing["input"] + output_tokens * pricing["output"]) / 1_000_000 def list_known_models() -> list[str]: diff --git a/tests/unit/test_integrations.py b/tests/unit/test_integrations.py index c36725ea..72d0ab69 100644 --- a/tests/unit/test_integrations.py +++ b/tests/unit/test_integrations.py @@ -1,36 +1,25 @@ """Unit tests for LangChain and LlamaIndex integrations.""" import sys -from typing import TYPE_CHECKING import pytest -# Skip all tests in this module if Python < 3.10 (required for | union syntax) -pytestmark = pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10+") - -if sys.version_info >= (3, 10): - from identark.integrations.langchain import ( - IdentArkChatModel, - identark_to_ai_message, - lc_to_identark, - ) - from identark.integrations.llamaindex import ( - IdentArkLLM, - identark_to_chat_response, - li_to_identark, - ) -else: - # Dummy objects for when Python < 3.10 - IdentArkChatModel = None # type: ignore - identark_to_ai_message = None # type: ignore - lc_to_identark = None # type: ignore - IdentArkLLM = None # type: ignore - identark_to_chat_response = None # type: ignore - li_to_identark = None # type: ignore - +from identark.integrations.langchain import ( + IdentArkChatModel, + identark_to_ai_message, + lc_to_identark, +) +from identark.integrations.llamaindex import ( + IdentArkLLM, + identark_to_chat_response, + li_to_identark, +) from identark.models import Function, LLMResponse, Message, Role, TokenUsage, ToolCall from identark.testing import MockGateway +# Skip all tests in this module if Python < 3.10 (required for | union syntax) +pytestmark = pytest.mark.skipif(sys.version_info < (3, 10), reason="Requires Python 3.10+") + # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -110,9 +99,7 @@ def test_mixed_conversation(self) -> None: HumanMessage(content="Thanks"), ] cs = lc_to_identark(lc_msgs) - assert [m.role for m in cs] == [ - Role.SYSTEM, Role.USER, Role.ASSISTANT, Role.USER - ] + assert [m.role for m in cs] == [Role.SYSTEM, Role.USER, Role.ASSISTANT, Role.USER] def test_multimodal_list_content(self) -> None: from langchain_core.messages import HumanMessage @@ -159,9 +146,7 @@ def test_malformed_tool_arguments_handled(self) -> None: cost_usd=0.001, model="mock", finish_reason="tool_calls", - tool_calls=[ - ToolCall(id="c1", function=Function(name="fn", arguments="not-json")) - ], + tool_calls=[ToolCall(id="c1", function=Function(name="fn", arguments="not-json"))], usage=TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0), ) ai_msg = identark_to_ai_message(response) @@ -193,10 +178,12 @@ async def test_gateway_receives_correct_messages(self) -> None: mock.queue_response(_make_response()) llm = _make_llm(mock) - await llm.ainvoke([ - SystemMessage(content="Be brief."), - HumanMessage(content="What is AI?"), - ]) + await llm.ainvoke( + [ + SystemMessage(content="Be brief."), + HumanMessage(content="What is AI?"), + ] + ) assert mock.invoke_llm_call_count == 1 sent = mock.last_request["new_messages"] @@ -367,8 +354,6 @@ def test_basic_text_response(self) -> None: assert resp.message.content == "Hello!" def test_raw_metadata_populated(self) -> None: - from llama_index.core.llms import ChatResponse - resp = identark_to_chat_response(_make_response(cost=0.007)) assert resp.raw["cost_usd"] == pytest.approx(0.007) assert resp.raw["model"] == "mock-gpt-4o" @@ -376,8 +361,6 @@ def test_raw_metadata_populated(self) -> None: assert resp.raw["output_tokens"] == 5 def test_tool_calls_in_additional_kwargs(self) -> None: - from llama_index.core.llms import ChatResponse - resp = identark_to_chat_response(_make_tool_response()) tcs = resp.message.additional_kwargs["tool_calls"] assert len(tcs) == 1 @@ -385,8 +368,6 @@ def test_tool_calls_in_additional_kwargs(self) -> None: assert tcs[0]["id"] == "call_abc123" def test_no_tool_calls_when_absent(self) -> None: - from llama_index.core.llms import ChatResponse - resp = identark_to_chat_response(_make_response()) assert "tool_calls" not in resp.message.additional_kwargs @@ -416,10 +397,12 @@ async def test_gateway_receives_correct_messages(self) -> None: mock.queue_response(_make_response()) llm = IdentArkLLM(gateway=mock) - await llm.achat([ - ChatMessage(role=MessageRole.SYSTEM, content="Be brief."), - ChatMessage(role=MessageRole.USER, content="What is AI?"), - ]) + await llm.achat( + [ + ChatMessage(role=MessageRole.SYSTEM, content="Be brief."), + ChatMessage(role=MessageRole.USER, content="What is AI?"), + ] + ) sent = mock.last_request["new_messages"] assert sent[0].role == Role.SYSTEM @@ -472,6 +455,7 @@ def test_metadata_has_model_name(self) -> None: def test_metadata_uses_gateway_model_attr(self) -> None: from identark import DirectGateway + gw = DirectGateway(llm_client=object(), model="gpt-4o") llm = IdentArkLLM(gateway=gw) assert llm.metadata.model_name == "gpt-4o"